+
diff --git a/app/javascript/dashboard/components-next/template-preview/WhatsAppTextTemplate.vue b/app/javascript/dashboard/components-next/template-preview/WhatsAppTextTemplate.vue
new file mode 100644
index 000000000..5e1d6f2de
--- /dev/null
+++ b/app/javascript/dashboard/components-next/template-preview/WhatsAppTextTemplate.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+ {{ message.title }}
+
+
+
+
+
+
+
+ {{ message.footer }}
+
+
+
diff --git a/app/javascript/dashboard/components-next/template-preview/index.js b/app/javascript/dashboard/components-next/template-preview/index.js
new file mode 100644
index 000000000..b7f1bd6b0
--- /dev/null
+++ b/app/javascript/dashboard/components-next/template-preview/index.js
@@ -0,0 +1,6 @@
+// Main Template Preview Component
+export { default as TemplatePreview } from './TemplatePreview.vue';
+
+// Core Services
+export { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
+export { TemplateNormalizer } from 'dashboard/services/TemplateNormalizer';
diff --git a/app/javascript/dashboard/components-next/template-preview/templates/twilio-templates.js b/app/javascript/dashboard/components-next/template-preview/templates/twilio-templates.js
new file mode 100644
index 000000000..a7857b27d
--- /dev/null
+++ b/app/javascript/dashboard/components-next/template-preview/templates/twilio-templates.js
@@ -0,0 +1,209 @@
+export const twilioTemplates = [
+ {
+ body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
+ types: {
+ 'twilio/media': {
+ body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
+ media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {
+ 1: 'Jordan',
+ 2: '100$',
+ },
+ content_sid: 'HX4b1ff075f097ccdf7f274b6af4d7be02',
+ friendly_name: 'shoe_launch',
+ template_type: 'media',
+ },
+ {
+ body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
+ types: {
+ 'twilio/media': {
+ body: 'Introducing our latest release the {{1}}! Available now for just {{2}}. Be among the first to own this style. Limited stock available!',
+ media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {
+ 1: 'Jordan',
+ 2: '400$',
+ },
+ content_sid: 'HXd5c1f8f8d68976f841c440d5e4b46c2e',
+ friendly_name: 'product_launch_custom_price',
+ template_type: 'media',
+ },
+ {
+ body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
+ types: {
+ 'twilio/media': {
+ body: 'Introducing our latest release the Nike Air Force! Available now for just $129.99',
+ media: ['https://vite-five-phi.vercel.app/jordan-shoes.jpg'],
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {},
+ content_sid: 'HX25f6e823f2416ca4b34254d98e916fae',
+ friendly_name: 'product_launch',
+ template_type: 'media',
+ },
+ {
+ body: 'Hey {{1}}, how may I help you?',
+ types: {
+ 'twilio/text': {
+ body: 'Hey {{1}}, how may I help you?',
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {
+ 1: 'John',
+ },
+ content_sid: 'HXee240fd3a8b5045dba057feda5173e55',
+ friendly_name: 'greet',
+ template_type: 'text',
+ },
+ {
+ body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
+ types: {
+ 'twilio/text': {
+ body: "Hi {{1}} Thanks for placing an order with us. We'll let you know once your order has been processed and delivered. Your order number is {{3}}. Thanks",
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {
+ 1: 'John',
+ 3: '12345',
+ },
+ content_sid: 'HX88291ef8d30d7dcd436cbb9b21c236f4',
+ friendly_name: 'order_status',
+ template_type: 'text',
+ },
+ {
+ body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
+ types: {
+ 'twilio/text': {
+ body: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {},
+ content_sid: 'HXdc6da32d489ee80f67c07d5bb0e7e390',
+ friendly_name: 'hello_world',
+ template_type: 'text',
+ },
+ {
+ body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
+ types: {
+ 'twilio/quick-reply': {
+ body: 'Thanks for reaching out to us. Before we proceed, we would like to get some information from you to assist you better. To whom would you like to connect?',
+ actions: [
+ {
+ id: 'Sales_payload',
+ title: 'Sales',
+ },
+ {
+ id: 'Support_payload',
+ title: 'Support',
+ },
+ ],
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en_US',
+ variables: {},
+ content_sid: 'HX778677f5867f96175ab4b7efb9a5bee6',
+ friendly_name: 'welcome_message_new',
+ template_type: 'quick_reply',
+ },
+ {
+ body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
+ types: {
+ 'twilio/quick-reply': {
+ body: 'What type of Chatwoot installation are you using? Select "Chatwoot Cloud" if you are using app.chatwoot.com, otherwise select "Self-hosted Chatwoot".',
+ actions: [
+ {
+ id: 'Chatwoot Cloud_payload',
+ title: 'Chatwoot Cloud',
+ },
+ {
+ id: 'Self-hosted Chatwoot_payload',
+ title: 'Self-hosted Chatwoot',
+ },
+ ],
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en_US',
+ variables: {},
+ content_sid: 'HX3a35e5cd76529fd91d19341deb4ef685',
+ friendly_name: 'saas_whatsapp_question',
+ template_type: 'quick_reply',
+ },
+ {
+ body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
+ types: {
+ 'twilio/quick-reply': {
+ body: 'Thanks for reaching out to us. Happy to help. What are you looking for?',
+ actions: [
+ {
+ id: 'Support_payload',
+ title: 'Support',
+ },
+ {
+ id: 'Sales_payload',
+ title: 'Sales',
+ },
+ {
+ id: 'Demo_payload',
+ title: 'Demo',
+ },
+ ],
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en_US',
+ variables: {},
+ content_sid: 'HX5d8e09f96cee2f7fb7bab223c03cb0a1',
+ friendly_name: 'welcome_message',
+ template_type: 'quick_reply',
+ },
+ {
+ types: {
+ 'twilio/call-to-action': {
+ actions: [
+ {
+ id: null,
+ title: 'Pay now',
+ type: 'URL',
+ url: 'https://payments.example.com/pay',
+ },
+ ],
+ body: 'Hello, this is a gentle reminder regarding your course fee.\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience',
+ },
+ },
+ status: 'approved',
+ category: 'utility',
+ language: 'en',
+ variables: {
+ date: '01-Jan-2026',
+ },
+ content_sid: 'HX63e56fc142aad670f320bf400d5bfeb7',
+ friendly_name: 'course_fee_reminder',
+ template_type: 'call_to_action',
+ },
+];
diff --git a/app/javascript/dashboard/components-next/template-preview/templates/whatsapp-templates.js b/app/javascript/dashboard/components-next/template-preview/templates/whatsapp-templates.js
new file mode 100644
index 000000000..fc70e3230
--- /dev/null
+++ b/app/javascript/dashboard/components-next/template-preview/templates/whatsapp-templates.js
@@ -0,0 +1,408 @@
+export const whatsAppTemplates = [
+ {
+ id: '1381151706284063',
+ name: 'event_invitation_static',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: 'F1',
+ param_name: 'event_name',
+ },
+ {
+ example: 'Dubai',
+ param_name: 'location',
+ },
+ ],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://events.example.com/register',
+ text: 'Visit website',
+ type: 'URL',
+ },
+ {
+ url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
+ text: 'Get Directions',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '767076159336759',
+ name: 'purchase_receipt',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'DOCUMENT',
+ example: {
+ header_handle: [
+ 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
+ ],
+ },
+ },
+ {
+ text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
+ type: 'BODY',
+ example: {
+ body_text: [['credit', 'CS Mutual', 'receipt']],
+ },
+ },
+ ],
+ parameter_format: 'POSITIONAL',
+ },
+ {
+ id: '1469258364071127',
+ name: 'discount_coupon',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: '30',
+ param_name: 'discount_percentage',
+ },
+ ],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Copy offer code',
+ type: 'COPY_CODE',
+ example: ['SAVE1OFF'],
+ },
+ ],
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '1075221534579807',
+ name: 'support_callback',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en',
+ components: [
+ {
+ text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: 'muhsin',
+ param_name: 'name',
+ },
+ {
+ example: '232323',
+ param_name: 'ticket_id',
+ },
+ ],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Call Support',
+ type: 'PHONE_NUMBER',
+ phone_number: '+2112121212',
+ },
+ ],
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '1023596726651144',
+ name: 'training_video',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'VIDEO',
+ example: {
+ header_handle: [
+ 'https://scontent.whatsapp.net/v/t61.29466-34/521582686_1023596729984477_1872358575355618432_n.mp4',
+ ],
+ },
+ },
+ {
+ text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: 'john',
+ param_name: 'name',
+ },
+ {
+ example: 'July 31',
+ param_name: 'date',
+ },
+ ],
+ },
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '1106685194739985',
+ name: 'order_confirmation',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ example: {
+ header_handle: ['https://vite-five-phi.vercel.app/vaporfly.png'],
+ },
+ },
+ {
+ text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
+ type: 'BODY',
+ example: {
+ body_text: [['blue canvas shoes']],
+ },
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'POSITIONAL',
+ },
+ {
+ id: '1242180011253003',
+ name: 'product_launch',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ example: {
+ header_handle: ['https://vite-five-phi.vercel.app/coat.png'],
+ },
+ },
+ {
+ text: 'New arrival! Our stunning coat now available in {{color}} color.',
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: 'blue',
+ param_name: 'color',
+ },
+ ],
+ },
+ },
+ {
+ text: 'Free shipping on orders over $100. Limited time offer.',
+ type: 'FOOTER',
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '1449876326175680',
+ name: 'technician_visit',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ text: 'Technician visit',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
+ type: 'BODY',
+ example: {
+ body_text: [
+ ['John', '123 Maple St', '2025-12-31', '10:00 AM', '2:00 PM'],
+ ],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ text: 'Confirm',
+ type: 'QUICK_REPLY',
+ },
+ {
+ text: 'Reschedule',
+ type: 'QUICK_REPLY',
+ },
+ ],
+ },
+ ],
+ parameter_format: 'POSITIONAL',
+ },
+ {
+ id: '997298832221901',
+ name: 'greet',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ text: 'Hey {{customer_name}} how may I help you?',
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: 'John',
+ param_name: 'customer_name',
+ },
+ ],
+ },
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '632315222954611',
+ name: 'hello_world',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ text: 'Hello World',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
+ type: 'BODY',
+ },
+ {
+ text: 'WhatsApp Business Platform sample message',
+ type: 'FOOTER',
+ },
+ ],
+ parameter_format: 'POSITIONAL',
+ },
+ {
+ id: '787864066907971',
+ name: 'feedback_request',
+ status: 'APPROVED',
+ category: 'MARKETING',
+ language: 'en',
+ components: [
+ {
+ text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
+ type: 'BODY',
+ example: {
+ body_text_named_params: [
+ {
+ example: 'muhsin',
+ param_name: 'name',
+ },
+ ],
+ },
+ },
+ {
+ type: 'BUTTONS',
+ buttons: [
+ {
+ url: 'https://feedback.example.com/survey',
+ text: 'Leave Feedback',
+ type: 'URL',
+ },
+ ],
+ },
+ ],
+ sub_category: 'CUSTOM',
+ parameter_format: 'NAMED',
+ },
+ {
+ id: '1938057163677205',
+ name: 'address_update',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ text: 'Address update',
+ type: 'HEADER',
+ format: 'TEXT',
+ },
+ {
+ text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
+ type: 'BODY',
+ example: {
+ body_text: [['John', '123 Main St', 'support@telco.com']],
+ },
+ },
+ ],
+ parameter_format: 'POSITIONAL',
+ },
+ {
+ id: '1644094842949394',
+ name: 'delivery_confirmation',
+ status: 'APPROVED',
+ category: 'UTILITY',
+ language: 'en_US',
+ components: [
+ {
+ text: '{{1}}, your order was successfully delivered on {{2}}.\\n\\nThank you for your purchase.\\n',
+ type: 'BODY',
+ example: {
+ body_text: [['John', 'Jan 1, 2024']],
+ },
+ },
+ ],
+ parameter_format: 'POSITIONAL',
+ },
+];
+
+// Helper function to get variable values from examples
+export const getWhatsAppVariables = template => {
+ const variables = {};
+ template.components?.forEach(component => {
+ if (component.example?.body_text_named_params) {
+ component.example.body_text_named_params.forEach(param => {
+ variables[param.param_name] = param.example;
+ });
+ }
+ if (component.example?.body_text) {
+ component.example.body_text[0]?.forEach((value, index) => {
+ variables[(index + 1).toString()] = value;
+ });
+ }
+ });
+ return variables;
+};
diff --git a/app/javascript/dashboard/components/CustomAttribute.vue b/app/javascript/dashboard/components/CustomAttribute.vue
index 8f70a9fa2..232cc9aac 100644
--- a/app/javascript/dashboard/components/CustomAttribute.vue
+++ b/app/javascript/dashboard/components/CustomAttribute.vue
@@ -102,13 +102,14 @@ export default {
return this.v$.editedValue.$error;
},
errorMessage() {
- if (this.v$.editedValue.url) {
+ if (this.v$.editedValue.url?.$invalid) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
- if (!this.v$.editedValue.regexValidation) {
- return this.regexCue
- ? this.regexCue
- : this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
+ if (this.v$.editedValue.regexValidation?.$invalid) {
+ return (
+ this.regexCue ||
+ this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT')
+ );
}
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.REQUIRED');
},
@@ -134,9 +135,12 @@ export default {
editedValue: {
required,
regexValidation: value => {
- return !(
- this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
- );
+ if (!this.attributeRegex || !value) return true;
+ try {
+ return getRegexp(this.attributeRegex).test(value);
+ } catch {
+ return false;
+ }
},
},
};
diff --git a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
index 1e1ab9756..8bd3e9457 100644
--- a/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
+++ b/app/javascript/dashboard/components/widgets/WootWriter/FullEditor.vue
@@ -13,6 +13,9 @@ import {
triggerCharacters,
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
+import embedPreviewPlugin from '@chatwoot/prosemirror-schema/src/plugins/embedPreview';
+import trailingParagraphPlugin from '@chatwoot/prosemirror-schema/src/plugins/trailingParagraph';
+import { embeds as markdownEmbeds } from 'dashboard/helper/markdownEmbeds';
import { toggleMark } from 'prosemirror-commands';
import { wrapInList } from 'prosemirror-schema-list';
import { toggleBlockType } from '@chatwoot/prosemirror-schema/src/menu/common';
@@ -77,6 +80,8 @@ export default {
plugins: [
imagePastePlugin(this.handleImageUpload),
this.createSlashPlugin(),
+ embedPreviewPlugin(markdownEmbeds),
+ trailingParagraphPlugin(),
],
isTextSelected: false, // Tracks text selection and prevents unnecessary re-renders on mouse selection
showSlashMenu: false,
@@ -113,6 +118,12 @@ export default {
this.focusEditorInputField();
}
},
+ beforeUnmount() {
+ if (editorView) {
+ editorView.destroy();
+ editorView = null;
+ }
+ },
methods: {
createSlashPlugin() {
return suggestionsPlugin({
@@ -488,4 +499,9 @@ export default {
max-height: 7.5rem;
overflow: auto;
}
+
+.ProseMirror .cw-embed-preview {
+ max-width: 36rem;
+ margin: 0.5rem 0 1rem;
+}
diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js
index 0d682821d..32a3bd370 100644
--- a/app/javascript/dashboard/composables/useCallSession.js
+++ b/app/javascript/dashboard/composables/useCallSession.js
@@ -1,11 +1,14 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
import VoiceAPI from 'dashboard/api/channel/voice/voiceAPIClient';
import TwilioVoiceClient from 'dashboard/api/channel/voice/twilioVoiceClient';
import { useCallsStore } from 'dashboard/stores/calls';
+import { useAlert } from 'dashboard/composables';
import Timer from 'dashboard/helper/Timer';
export function useCallSession() {
const callsStore = useCallsStore();
+ const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -74,6 +77,11 @@ export function useCallSession() {
return { conferenceSid: joinResponse?.conference_sid };
} catch (error) {
+ useAlert(error?.response?.data?.error || t('CONTACT_PANEL.CALL_FAILED'));
+ if (error?.response?.status === 409) {
+ TwilioVoiceClient.endClientCall();
+ callsStore.dismissCall(callSid);
+ }
// eslint-disable-next-line no-console
console.error('Failed to join call:', error);
return null;
diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js
index 9839a04e0..491dd9d44 100644
--- a/app/javascript/dashboard/helper/editorHelper.js
+++ b/app/javascript/dashboard/helper/editorHelper.js
@@ -123,6 +123,13 @@ export function cleanSignature(signature) {
}
}
+// Strip `\
` hardbreak markers trailing `--` after a signature slice
+const stripDelimiterHardbreaks = body =>
+ body.replace(/(--)\s*(?:\\\s*)+$/, '$1');
+
+// Strip standalone blank-paragraph markers (`\` on their own lines).
+const stripTrailingBlankLine = body => body.replace(/\n(?:\s*\\\n)+$/, '');
+
/**
* Adds the signature delimiter to the beginning of the signature.
*
@@ -227,13 +234,19 @@ export function removeSignature(body, signature, channelType) {
// trimming will ensure any spaces or new lines before the signature are removed
// This means we will have the delimiter at the end
if (signatureIndex > -1) {
- newBody = newBody.substring(0, signatureIndex).trimEnd();
+ newBody = stripDelimiterHardbreaks(
+ newBody.substring(0, signatureIndex)
+ ).trimEnd();
}
// Remove delimiter if it's at the end
if (newBody.endsWith(SIGNATURE_DELIMITER)) {
// if the delimiter is at the end, remove it
newBody = newBody.slice(0, -SIGNATURE_DELIMITER.length);
+ // strip any trailing blank-line markers
+ if (signatureIndex > -1) {
+ newBody = stripTrailingBlankLine(newBody);
+ }
}
return newBody;
diff --git a/app/javascript/dashboard/helper/markdownEmbeds.js b/app/javascript/dashboard/helper/markdownEmbeds.js
new file mode 100644
index 000000000..116290220
--- /dev/null
+++ b/app/javascript/dashboard/helper/markdownEmbeds.js
@@ -0,0 +1,11 @@
+import config from '../../../../config/markdown_embeds.yml';
+
+// Gists rely on document.write() and can't render inline in the editor.
+const NON_PREVIEWABLE_EMBEDS = new Set(['github_gist']);
+
+export const embeds = Object.entries(config)
+ .filter(([key]) => !NON_PREVIEWABLE_EMBEDS.has(key))
+ .map(([, { regex, template }]) => ({
+ regex: new RegExp(regex),
+ template,
+ }));
diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
index b06c36d42..c7822bde7 100644
--- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js
+++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js
@@ -336,6 +336,38 @@ describe('removeSignature', () => {
'This is a test\n\n'
);
});
+ it('strips blank-paragraph marker before the delimiter', () => {
+ expect(removeSignature('hey\n\n\\\n--\n\nHello there', 'Hello there')).toBe(
+ 'hey'
+ );
+ });
+ it('strips multiple consecutive blank-paragraph markers before the delimiter', () => {
+ expect(
+ removeSignature('wewe\n\n\\\n\\\n\\\n--\n\nHello there', 'Hello there')
+ ).toBe('wewe');
+ });
+ it('strips dangling hardbreak when signature shared a paragraph with "--"', () => {
+ expect(removeSignature('hey\n\n--\\\nHello there', 'Hello there')).toBe(
+ 'hey\n\n'
+ );
+ });
+ it('preserves trailing backslash in user text when appending', () => {
+ expect(appendSignature('The path is C:\\', 'Best\nAgent')).toContain(
+ 'C:\\'
+ );
+ expect(appendSignature('C:\\\n', 'Best\nAgent')).toContain('C:\\');
+ expect(appendSignature('C:\\\n\n', 'Best\nAgent')).toContain('C:\\');
+ });
+ it('preserves trailing backslash in user text when removing', () => {
+ expect(removeSignature('C:\\\n--\n\nBest\nAgent', 'Best\nAgent')).toContain(
+ 'C:\\'
+ );
+ expect(removeSignature('C:\\\n--', 'no matching sig')).toContain('C:\\');
+ expect(removeSignature('C:\\\nBest\\\nAgent', 'Best\nAgent')).toContain(
+ 'C:\\'
+ );
+ expect(removeSignature('notes\n\\\n--', 'no matching sig')).toContain('\\');
+ });
});
describe('removeSignature with stripped signature', () => {
diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js
index d6a6af6bd..dc205f8ea 100644
--- a/app/javascript/dashboard/helper/voice.js
+++ b/app/javascript/dashboard/helper/voice.js
@@ -22,6 +22,29 @@ const shouldSkipCall = (callDirection, senderId, currentUserId) => {
return callDirection === 'outbound' && senderId !== currentUserId;
};
+const extractAssigneeId = conversation => {
+ return conversation?.assignee_id || conversation?.meta?.assignee?.id || null;
+};
+
+const isAssignedToAnotherAgent = (assigneeId, currentUserId) => {
+ if (currentUserId == null) return false;
+ return !!assigneeId && assigneeId !== currentUserId;
+};
+
+const shouldShowCall = ({
+ callDirection,
+ senderId,
+ assigneeId,
+ currentUserId,
+}) => {
+ if (shouldSkipCall(callDirection, senderId, currentUserId)) return false;
+ // Outbound calls are scoped to the initiator via shouldSkipCall; the
+ // conversation may be auto-assigned to a different agent on creation, so
+ // skip the assignee filter for outbound to avoid hiding the caller's own widget.
+ if (callDirection === 'outbound') return true;
+ return !isAssignedToAnotherAgent(assigneeId, currentUserId);
+};
+
function extractCallData(message) {
const call = message?.call || {};
return {
@@ -29,6 +52,7 @@ function extractCallData(message) {
status: call.status,
callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound',
conversationId: message?.conversation_id,
+ assigneeId: extractAssigneeId(message?.conversation),
senderId: message?.sender?.id,
};
}
@@ -36,10 +60,19 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
- const { callSid, callDirection, conversationId, senderId } =
+ const { callSid, callDirection, conversationId, assigneeId, senderId } =
extractCallData(message);
- if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
+ if (
+ !shouldShowCall({
+ callDirection,
+ senderId,
+ assigneeId,
+ currentUserId,
+ })
+ ) {
+ return;
+ }
const callsStore = useCallsStore();
callsStore.addCall({
@@ -53,8 +86,14 @@ export function handleVoiceCallCreated(message, currentUserId) {
export function handleVoiceCallUpdated(commit, message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
- const { callSid, status, callDirection, conversationId, senderId } =
- extractCallData(message);
+ const {
+ callSid,
+ status,
+ callDirection,
+ conversationId,
+ assigneeId,
+ senderId,
+ } = extractCallData(message);
const callsStore = useCallsStore();
@@ -66,11 +105,19 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
callSid,
});
- const isNewCall =
- status === 'ringing' &&
- !shouldSkipCall(callDirection, senderId, currentUserId);
+ if (
+ !shouldShowCall({
+ callDirection,
+ senderId,
+ assigneeId,
+ currentUserId,
+ })
+ ) {
+ callsStore.removeCall(callSid);
+ return;
+ }
- if (isNewCall) {
+ if (status === 'ringing') {
callsStore.addCall({
callSid,
conversationId,
@@ -79,3 +126,11 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) {
});
}
}
+
+export function syncConversationCallVisibility(conversation, currentUserId) {
+ const assigneeId = extractAssigneeId(conversation);
+ if (!isAssignedToAnotherAgent(assigneeId, currentUserId)) return;
+
+ const callsStore = useCallsStore();
+ callsStore.removeCallsForConversation(conversation.id);
+}
diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json
index 1a2edb2b0..88e66ebe3 100644
--- a/app/javascript/dashboard/i18n/locale/en/conversation.json
+++ b/app/javascript/dashboard/i18n/locale/en/conversation.json
@@ -83,7 +83,9 @@
"CALL_ENDED": "Call ended",
"NOT_ANSWERED_YET": "Not answered yet",
"THEY_ANSWERED": "They answered",
- "YOU_ANSWERED": "You answered"
+ "YOU_ANSWERED": "You answered",
+ "AGENT_ANSWERED": "{agentName} answered",
+ "JOIN_CALL": "Join call"
},
"HEADER": {
"RESOLVE_ACTION": "Resolve",
diff --git a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js
index 241c70f45..d58617f30 100644
--- a/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js
+++ b/app/javascript/dashboard/routes/dashboard/conversation/conversation.routes.js
@@ -1,5 +1,6 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../helper/URLHelper';
+import store from '../../../store';
import ConversationView from './ConversationView.vue';
const CONVERSATION_PERMISSIONS = [
@@ -10,6 +11,37 @@ const CONVERSATION_PERMISSIONS = [
'conversation_participating_manage',
];
+const isFolderAvailable = async folderId => {
+ let folders = store.getters['customViews/getConversationCustomViews'];
+ if (!folders.length) {
+ await store.dispatch('customViews/get', 'conversation');
+ folders = store.getters['customViews/getConversationCustomViews'];
+ }
+ return folders.some(folder => folder.id === Number(folderId));
+};
+
+const redirectFolderListIfUnavailable = async (to, _from, next) => {
+ if (await isFolderAvailable(to.params.id)) {
+ next();
+ return;
+ }
+ next({ name: 'home', params: { accountId: to.params.accountId } });
+};
+
+const redirectFolderConversationIfUnavailable = async (to, _from, next) => {
+ if (await isFolderAvailable(to.params.id)) {
+ next();
+ return;
+ }
+ next({
+ name: 'inbox_conversation',
+ params: {
+ accountId: to.params.accountId,
+ conversation_id: to.params.conversation_id,
+ },
+ });
+};
+
export default {
routes: [
{
@@ -113,6 +145,7 @@ export default {
meta: {
permissions: CONVERSATION_PERMISSIONS,
},
+ beforeEnter: redirectFolderListIfUnavailable,
component: ConversationView,
props: route => ({ foldersId: route.params.id }),
},
@@ -125,6 +158,7 @@ export default {
permissions: CONVERSATION_PERMISSIONS,
},
component: ConversationView,
+ beforeEnter: redirectFolderConversationIfUnavailable,
props: route => ({
conversationId: route.params.conversation_id,
foldersId: route.params.id,
diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue
index 9b097f6a1..8d85d56af 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/AddAttribute.vue
@@ -4,6 +4,7 @@ import { required, minLength } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { convertToAttributeSlug } from 'dashboard/helper/commons.js';
+import { normalizeRegexPattern } from 'shared/helpers/Validators';
import { ATTRIBUTE_MODELS, ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
@@ -99,19 +100,10 @@ export default {
},
validations: {
- displayName: {
- required,
- minLength: minLength(1),
- },
- description: {
- required,
- },
- attributeModel: {
- required,
- },
- attributeType: {
- required,
- },
+ displayName: { required, minLength: minLength(1) },
+ description: { required },
+ attributeModel: { required },
+ attributeType: { required },
attributeKey: {
required,
isKey(value) {
@@ -151,9 +143,7 @@ export default {
attribute_display_type: this.attributeType,
attribute_key: this.attributeKey,
attribute_values: this.attributeListValues,
- regex_pattern: this.regexPattern
- ? new RegExp(this.regexPattern).toString()
- : null,
+ regex_pattern: normalizeRegexPattern(this.regexPattern),
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.ADD.API.SUCCESS_MESSAGE');
diff --git a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue
index 35cba6a86..308ea35c7 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/attributes/EditAttribute.vue
@@ -2,7 +2,7 @@
import { useVuelidate } from '@vuelidate/core';
import { useAlert } from 'dashboard/composables';
import { required, minLength } from '@vuelidate/validators';
-import { getRegexp } from 'shared/helpers/Validators';
+import { getRegexp, normalizeRegexPattern } from 'shared/helpers/Validators';
import { ATTRIBUTE_TYPES } from './constants';
import NextButton from 'dashboard/components-next/button/Button.vue';
import TagInput from 'dashboard/components-next/taginput/TagInput.vue';
@@ -41,16 +41,9 @@ export default {
};
},
validations: {
- displayName: {
- required,
- },
- attributeType: {
- required,
- },
- description: {
- required,
- minLength: minLength(1),
- },
+ displayName: { required },
+ attributeType: { required },
+ description: { required, minLength: minLength(1) },
attributeKey: {
required,
isKey(value) {
@@ -118,7 +111,7 @@ export default {
},
setFormValues() {
const regexPattern = this.selectedAttribute.regex_pattern
- ? getRegexp(this.selectedAttribute.regex_pattern).source
+ ? getRegexp(this.selectedAttribute.regex_pattern).toString()
: null;
this.displayName = this.selectedAttribute.attribute_display_name;
this.description = this.selectedAttribute.attribute_description;
@@ -144,9 +137,7 @@ export default {
attribute_description: this.description,
attribute_display_name: this.displayName,
attribute_values: this.updatedAttributeListValues,
- regex_pattern: this.regexPattern
- ? new RegExp(this.regexPattern).toString()
- : null,
+ regex_pattern: normalizeRegexPattern(this.regexPattern),
regex_cue: this.regexCue,
});
this.alertMessage = this.$t('ATTRIBUTES_MGMT.EDIT.API.SUCCESS_MESSAGE');
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
index dc247da6f..d955883eb 100644
--- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
+++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/CustomerSatisfactionPage.vue
@@ -11,7 +11,7 @@ import Icon from 'dashboard/components-next/icon/Icon.vue';
import WithLabel from 'v3/components/Form/WithLabel.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import CSATDisplayTypeSelector from './components/CSATDisplayTypeSelector.vue';
-import CSATTemplate from 'dashboard/components-next/message/bubbles/Template/CSAT.vue';
+import CSATTemplate from 'dashboard/components-next/template-preview/CSATTemplate.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import FilterSelect from 'dashboard/components-next/filter/inputs/FilterSelect.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
diff --git a/app/javascript/dashboard/services/TemplateConstants.js b/app/javascript/dashboard/services/TemplateConstants.js
new file mode 100644
index 000000000..6474372c4
--- /dev/null
+++ b/app/javascript/dashboard/services/TemplateConstants.js
@@ -0,0 +1,58 @@
+export const PLATFORMS = {
+ WHATSAPP: 'whatsapp',
+ TWILIO: 'twilio',
+};
+
+export const WA_COMPONENT_TYPES = {
+ HEADER: 'HEADER',
+ BODY: 'BODY',
+ FOOTER: 'FOOTER',
+ BUTTONS: 'BUTTONS',
+};
+
+export const WA_HEADER_FORMATS = {
+ TEXT: 'TEXT',
+ IMAGE: 'IMAGE',
+ VIDEO: 'VIDEO',
+ DOCUMENT: 'DOCUMENT',
+};
+
+export const WA_MEDIA_FORMATS = [
+ WA_HEADER_FORMATS.IMAGE,
+ WA_HEADER_FORMATS.VIDEO,
+ WA_HEADER_FORMATS.DOCUMENT,
+];
+
+export const WA_BUTTON_TYPES = {
+ COPY_CODE: 'COPY_CODE',
+};
+
+export const WA_PARAM_FORMATS = {
+ POSITIONAL: 'POSITIONAL',
+ NAMED: 'NAMED',
+};
+
+export const TWILIO_TYPE_PREFIX = 'twilio/';
+
+export const TWILIO_TYPES = {
+ TEXT: 'text',
+ MEDIA: 'media',
+ QUICK_REPLY: 'quick_reply',
+ CALL_TO_ACTION: 'call_to_action',
+ CATALOG: 'catalog',
+};
+
+export const TEMPLATE_TYPES = {
+ WHATSAPP_TEXT: 'whatsapp-text',
+ WHATSAPP_TEXT_HEADER: 'whatsapp-text-header',
+ WHATSAPP_MEDIA_IMAGE: 'whatsapp-media-image',
+ WHATSAPP_MEDIA_VIDEO: 'whatsapp-media-video',
+ WHATSAPP_MEDIA_DOCUMENT: 'whatsapp-media-document',
+ WHATSAPP_INTERACTIVE: 'whatsapp-interactive',
+ WHATSAPP_COPY_CODE: 'whatsapp-copy-code',
+ TWILIO_TEXT: 'twilio-text',
+ TWILIO_MEDIA: 'twilio-media',
+ TWILIO_QUICK_REPLY: 'twilio-quick-reply',
+ TWILIO_CALL_TO_ACTION: 'twilio-call-to-action',
+ TWILIO_CARD: 'twilio-card',
+};
diff --git a/app/javascript/dashboard/services/TemplateNormalizer.js b/app/javascript/dashboard/services/TemplateNormalizer.js
new file mode 100644
index 000000000..588415815
--- /dev/null
+++ b/app/javascript/dashboard/services/TemplateNormalizer.js
@@ -0,0 +1,114 @@
+import { TemplateTypeDetector } from './TemplateTypeDetector';
+import {
+ PLATFORMS,
+ TWILIO_TYPE_PREFIX,
+ WA_COMPONENT_TYPES,
+ WA_PARAM_FORMATS,
+} from './TemplateConstants';
+
+/**
+ * TemplateNormalizer - Convert platform-specific formats to unified structure
+ */
+export class TemplateNormalizer {
+ static normalizeWhatsApp(template) {
+ const components = template.components || [];
+
+ return {
+ id: template.id,
+ name: template.name,
+ platform: PLATFORMS.WHATSAPP,
+ type: TemplateTypeDetector.detectWhatsAppType(template),
+ parameterFormat: template.parameter_format || WA_PARAM_FORMATS.POSITIONAL,
+ header: components.find(c => c.type === WA_COMPONENT_TYPES.HEADER),
+ body: components.find(c => c.type === WA_COMPONENT_TYPES.BODY),
+ footer: components.find(c => c.type === WA_COMPONENT_TYPES.FOOTER),
+ buttons:
+ components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS)?.buttons ||
+ [],
+ variables: this.extractWhatsAppVariables(template),
+ category: template.category,
+ language: template.language,
+ originalTemplate: template,
+ };
+ }
+
+ static normalizeTwilio(template) {
+ const typeKey =
+ template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
+ Object.keys(template.types || {})
+ .map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
+ .find(key => key);
+
+ const hyphenatedKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/_/g, '-')}`;
+ const underscoreKey = `${TWILIO_TYPE_PREFIX}${(typeKey || '').replace(/-/g, '_')}`;
+ const typeData =
+ template.types?.[hyphenatedKey] || template.types?.[underscoreKey] || {};
+
+ return {
+ contentSid: template.content_sid,
+ name: template.friendly_name,
+ platform: PLATFORMS.TWILIO,
+ type: TemplateTypeDetector.detectTwilioType(template),
+ body: template.body || typeData.body,
+ media: typeData.media || [],
+ mediaType: template.media_type || null,
+ actions: typeData.actions || [],
+ variables: template.variables || {},
+ category: template.category || 'utility',
+ language: template.language || 'en',
+ originalTemplate: template,
+ };
+ }
+
+ static extractWhatsAppVariables(template) {
+ const variables = {};
+ const components = template.components || [];
+
+ components.forEach(component => {
+ if (component.text) {
+ const matches = component.text.match(/\{\{([^}]+)\}\}/g) || [];
+ matches.forEach(match => {
+ const variable = match.replace(/[{}]/g, '');
+
+ if (template.parameter_format === WA_PARAM_FORMATS.NAMED) {
+ const example =
+ component.example?.body_text_named_params?.find(
+ p => p.param_name === variable
+ )?.example || '';
+ variables[variable] = example;
+ } else {
+ const position = parseInt(variable, 10) - 1;
+ const example = component.example?.body_text?.[0]?.[position] || '';
+ variables[variable] = example;
+ }
+ });
+ }
+
+ if (component.buttons) {
+ component.buttons.forEach(button => {
+ if (button.url) {
+ const matches = button.url.match(/\{\{([^}]+)\}\}/g) || [];
+ matches.forEach(match => {
+ const variable = match.replace(/[{}]/g, '');
+ const example = button.example?.[0] || '';
+ variables[variable] = example;
+ });
+ }
+ });
+ }
+ });
+
+ return variables;
+ }
+
+ static normalize(template, platform) {
+ switch (platform) {
+ case PLATFORMS.WHATSAPP:
+ return this.normalizeWhatsApp(template);
+ case PLATFORMS.TWILIO:
+ return this.normalizeTwilio(template);
+ default:
+ throw new Error(`Unsupported platform: ${platform}`);
+ }
+ }
+}
diff --git a/app/javascript/dashboard/services/TemplateNormalizer.spec.js b/app/javascript/dashboard/services/TemplateNormalizer.spec.js
new file mode 100644
index 000000000..4b9e2a9e0
--- /dev/null
+++ b/app/javascript/dashboard/services/TemplateNormalizer.spec.js
@@ -0,0 +1,143 @@
+// @vitest-environment node
+
+import { describe, it, expect } from 'vitest';
+
+import { TemplateNormalizer } from './TemplateNormalizer';
+
+describe('TemplateNormalizer', () => {
+ it('normalizes Twilio call-to-action templates with hyphenated keys', () => {
+ const template = {
+ types: {
+ 'twilio/call-to-action': {
+ body: 'CTA body for {{date}}',
+ actions: [
+ {
+ id: null,
+ title: 'Pay now',
+ type: 'URL',
+ url: 'https://payments.example.com/pay',
+ },
+ ],
+ },
+ },
+ variables: {
+ date: '01-Jan-2026',
+ },
+ content_sid: 'HX123',
+ friendly_name: 'cta_example',
+ template_type: 'call-to-action',
+ };
+
+ const normalized = TemplateNormalizer.normalizeTwilio(template);
+
+ expect(normalized.type).toBe('twilio-call-to-action');
+ expect(normalized.body).toBe('CTA body for {{date}}');
+ expect(normalized.actions).toHaveLength(1);
+ expect(normalized.variables).toEqual({ date: '01-Jan-2026' });
+ });
+
+ it('normalizes Twilio quick replies', () => {
+ const template = {
+ template_type: 'quick_reply',
+ types: {
+ 'twilio/quick-reply': {
+ body: 'Pick an option',
+ actions: [{ id: 'a', title: 'Option A' }],
+ },
+ },
+ variables: {},
+ };
+
+ const normalized = TemplateNormalizer.normalizeTwilio(template);
+
+ expect(normalized.type).toBe('twilio-quick-reply');
+ expect(normalized.body).toBe('Pick an option');
+ expect(normalized.actions).toEqual([{ id: 'a', title: 'Option A' }]);
+ });
+
+ it('normalizes Twilio media templates', () => {
+ const template = {
+ template_type: 'media',
+ body: 'Media body {{1}}',
+ types: {
+ 'twilio/media': {
+ body: 'Media body {{1}}',
+ media: ['https://example.com/image.jpg'],
+ },
+ },
+ variables: { 1: 'value' },
+ };
+
+ const normalized = TemplateNormalizer.normalizeTwilio(template);
+
+ expect(normalized.type).toBe('twilio-media');
+ expect(normalized.media).toEqual(['https://example.com/image.jpg']);
+ expect(normalized.body).toBe('Media body {{1}}');
+ });
+
+ it('extracts WhatsApp named variables', () => {
+ const template = {
+ parameter_format: 'NAMED',
+ components: [
+ {
+ type: 'BODY',
+ text: 'Hi {{name}}',
+ example: {
+ body_text_named_params: [{ param_name: 'name', example: 'John' }],
+ },
+ },
+ ],
+ };
+
+ const variables = TemplateNormalizer.extractWhatsAppVariables(template);
+
+ expect(variables).toMatchObject({
+ name: 'John',
+ });
+ });
+
+ it('extracts WhatsApp positional variables', () => {
+ const template = {
+ parameter_format: 'POSITIONAL',
+ components: [
+ {
+ type: 'BODY',
+ text: 'Hi {{1}}',
+ example: {
+ body_text: [['positional']],
+ },
+ },
+ ],
+ };
+
+ const variables = TemplateNormalizer.extractWhatsAppVariables(template);
+
+ expect(variables).toMatchObject({
+ 1: 'positional',
+ });
+ });
+
+ it('normalizes WhatsApp media image templates', () => {
+ const template = {
+ id: '1',
+ name: 'order_confirmation',
+ parameter_format: 'POSITIONAL',
+ components: [
+ {
+ type: 'HEADER',
+ format: 'IMAGE',
+ example: { header_handle: ['https://example.com/image.jpg'] },
+ },
+ { type: 'BODY', text: 'Hi {{1}}', example: { body_text: [['John']] } },
+ ],
+ language: 'en',
+ };
+
+ const normalized = TemplateNormalizer.normalizeWhatsApp(template);
+
+ expect(normalized.type).toBe('whatsapp-media-image');
+ expect(normalized.header.format).toBe('IMAGE');
+ expect(normalized.body.text).toBe('Hi {{1}}');
+ expect(normalized.variables).toMatchObject({ 1: 'John' });
+ });
+});
diff --git a/app/javascript/dashboard/services/TemplateTypeDetector.js b/app/javascript/dashboard/services/TemplateTypeDetector.js
new file mode 100644
index 000000000..c7d1859c7
--- /dev/null
+++ b/app/javascript/dashboard/services/TemplateTypeDetector.js
@@ -0,0 +1,63 @@
+import {
+ TEMPLATE_TYPES,
+ TWILIO_TYPE_PREFIX,
+ TWILIO_TYPES,
+ WA_BUTTON_TYPES,
+ WA_COMPONENT_TYPES,
+ WA_HEADER_FORMATS,
+} from './TemplateConstants';
+
+/**
+ * TemplateTypeDetector - Unified service to identify template types across platforms
+ */
+export class TemplateTypeDetector {
+ static detectWhatsAppType(template) {
+ const components = template.components || [];
+ const header = components.find(c => c.type === WA_COMPONENT_TYPES.HEADER);
+ const buttons = components.find(c => c.type === WA_COMPONENT_TYPES.BUTTONS);
+
+ if (header?.format === WA_HEADER_FORMATS.IMAGE)
+ return TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE;
+ if (header?.format === WA_HEADER_FORMATS.VIDEO)
+ return TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO;
+ if (header?.format === WA_HEADER_FORMATS.DOCUMENT)
+ return TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT;
+
+ if (buttons) {
+ const hasCopyCode = buttons.buttons?.some(
+ b => b.type === WA_BUTTON_TYPES.COPY_CODE
+ );
+ if (hasCopyCode) return TEMPLATE_TYPES.WHATSAPP_COPY_CODE;
+ return TEMPLATE_TYPES.WHATSAPP_INTERACTIVE;
+ }
+
+ if (header?.format === WA_HEADER_FORMATS.TEXT)
+ return TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER;
+ return TEMPLATE_TYPES.WHATSAPP_TEXT;
+ }
+
+ static detectTwilioType(template) {
+ const typeFromTemplate =
+ template.template_type?.replace(TWILIO_TYPE_PREFIX, '') ||
+ Object.keys(template.types || {})
+ .map(key => key.replace(TWILIO_TYPE_PREFIX, ''))
+ .find(typeKey => typeKey);
+
+ const templateType = (typeFromTemplate || '')
+ .replace(/-/g, '_')
+ .replace(/__/g, '_');
+
+ switch (templateType) {
+ case TWILIO_TYPES.MEDIA:
+ return TEMPLATE_TYPES.TWILIO_MEDIA;
+ case TWILIO_TYPES.QUICK_REPLY:
+ return TEMPLATE_TYPES.TWILIO_QUICK_REPLY;
+ case TWILIO_TYPES.CALL_TO_ACTION:
+ return TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION;
+ case TWILIO_TYPES.CATALOG:
+ return TEMPLATE_TYPES.TWILIO_CARD;
+ default:
+ return TEMPLATE_TYPES.TWILIO_TEXT;
+ }
+ }
+}
diff --git a/app/javascript/dashboard/services/TemplateTypeDetector.spec.js b/app/javascript/dashboard/services/TemplateTypeDetector.spec.js
new file mode 100644
index 000000000..ddf1a7593
--- /dev/null
+++ b/app/javascript/dashboard/services/TemplateTypeDetector.spec.js
@@ -0,0 +1,106 @@
+// @vitest-environment node
+
+import { describe, it, expect } from 'vitest';
+
+import { TemplateTypeDetector } from './TemplateTypeDetector';
+
+describe('TemplateTypeDetector', () => {
+ it('detects Twilio call-to-action from hyphenated template_type', () => {
+ const template = {
+ template_type: 'call-to-action',
+ types: {
+ 'twilio/call-to-action': {},
+ },
+ };
+
+ expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
+ 'twilio-call-to-action'
+ );
+ });
+
+ it('detects Twilio call-to-action when only types key is present', () => {
+ const template = {
+ types: {
+ 'twilio/call_to_action': {},
+ },
+ };
+
+ expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
+ 'twilio-call-to-action'
+ );
+ });
+
+ it('detects Twilio quick reply', () => {
+ const template = {
+ template_type: 'quick_reply',
+ types: {
+ 'twilio/quick-reply': {},
+ },
+ };
+
+ expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
+ 'twilio-quick-reply'
+ );
+ });
+
+ it('detects WhatsApp text-with-header and media', () => {
+ const base = {
+ components: [
+ { type: 'HEADER', format: 'TEXT', text: 'Header' },
+ { type: 'BODY', text: 'Body' },
+ ],
+ };
+
+ expect(TemplateTypeDetector.detectWhatsAppType(base)).toBe(
+ 'whatsapp-text-header'
+ );
+
+ const withImage = {
+ ...base,
+ components: [{ type: 'HEADER', format: 'IMAGE' }, base.components[1]],
+ };
+ expect(TemplateTypeDetector.detectWhatsAppType(withImage)).toBe(
+ 'whatsapp-media-image'
+ );
+ });
+
+ it('detects WhatsApp copy code vs call-to-action buttons', () => {
+ const copyCode = {
+ components: [
+ { type: 'BODY', text: 'Hi' },
+ {
+ type: 'BUTTONS',
+ buttons: [{ type: 'COPY_CODE', text: 'Copy offer code' }],
+ },
+ ],
+ };
+
+ const callToAction = {
+ components: [
+ { type: 'BODY', text: 'Hi' },
+ {
+ type: 'BUTTONS',
+ buttons: [{ type: 'URL', text: 'Visit website' }],
+ },
+ ],
+ };
+
+ expect(TemplateTypeDetector.detectWhatsAppType(copyCode)).toBe(
+ 'whatsapp-copy-code'
+ );
+ expect(TemplateTypeDetector.detectWhatsAppType(callToAction)).toBe(
+ 'whatsapp-interactive'
+ );
+ });
+
+ it('detects Twilio media', () => {
+ const template = {
+ template_type: 'media',
+ types: { 'twilio/media': {} },
+ };
+
+ expect(TemplateTypeDetector.detectTwilioType(template)).toBe(
+ 'twilio-media'
+ );
+ });
+});
diff --git a/app/javascript/dashboard/store/modules/conversations/actions.js b/app/javascript/dashboard/store/modules/conversations/actions.js
index 7ee2561c1..da5fc94bf 100644
--- a/app/javascript/dashboard/store/modules/conversations/actions.js
+++ b/app/javascript/dashboard/store/modules/conversations/actions.js
@@ -16,6 +16,7 @@ import * as Sentry from '@sentry/vue';
import {
handleVoiceCallCreated,
handleVoiceCallUpdated,
+ syncConversationCallVisibility,
} from 'dashboard/helper/voice';
export const hasMessageFailedWithExternalError = pendingMessage => {
@@ -393,19 +394,18 @@ const actions = {
}
},
- updateConversation({ commit, dispatch }, conversation) {
- const {
- meta: { sender },
- } = conversation;
+ updateConversation({ commit, dispatch, rootGetters }, conversation) {
+ const sender = conversation.meta?.sender;
commit(types.UPDATE_CONVERSATION, conversation);
+ syncConversationCallVisibility(conversation, rootGetters?.getCurrentUserID);
dispatch('conversationLabels/setConversationLabel', {
id: conversation.id,
data: conversation.labels,
});
- dispatch('contacts/setContact', sender);
+ if (sender) dispatch('contacts/setContact', sender);
},
updateConversationLastActivity(
diff --git a/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js b/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
index c4c6a0b40..093e9ddf5 100644
--- a/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
+++ b/app/javascript/dashboard/store/modules/specs/inboxes/templateFixtures.js
@@ -368,7 +368,7 @@ export const templates = [
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
- text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
+ text: 'Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
},
{
@@ -400,7 +400,7 @@ export const templates = [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
- phone_number: '+16506677566',
+ phone_number: '+23232323',
},
],
},
diff --git a/app/javascript/dashboard/stores/calls.js b/app/javascript/dashboard/stores/calls.js
index a153f158d..4b58b8bb8 100644
--- a/app/javascript/dashboard/stores/calls.js
+++ b/app/javascript/dashboard/stores/calls.js
@@ -55,5 +55,19 @@ export const useCallsStore = defineStore('calls', {
dismissCall(callSid) {
this.calls = this.calls.filter(call => call.callSid !== callSid);
},
+
+ removeCallsForConversation(conversationId) {
+ const callsToRemove = this.calls.filter(
+ call => call.conversationId === conversationId
+ );
+
+ if (callsToRemove.some(call => call.isActive)) {
+ TwilioVoiceClient.endClientCall();
+ }
+
+ this.calls = this.calls.filter(
+ call => call.conversationId !== conversationId
+ );
+ },
},
});
diff --git a/app/javascript/shared/helpers/MessageFormatter.js b/app/javascript/shared/helpers/MessageFormatter.js
index c87209fd4..825132e13 100644
--- a/app/javascript/shared/helpers/MessageFormatter.js
+++ b/app/javascript/shared/helpers/MessageFormatter.js
@@ -42,6 +42,7 @@ const createMarkdownInstance = (linkify = true) => {
quotes: '\u201c\u201d\u2018\u2019',
maxNesting: 20,
})
+ .disable(['lheading'])
.use(mentionPlugin)
.use(imgResizeManager)
.use(mila, {
diff --git a/app/javascript/shared/helpers/Validators.js b/app/javascript/shared/helpers/Validators.js
index 1c10121f1..a3547a5ca 100644
--- a/app/javascript/shared/helpers/Validators.js
+++ b/app/javascript/shared/helpers/Validators.js
@@ -101,6 +101,22 @@ export const getRegexp = regexPatternValue => {
);
};
+/**
+ * Normalises a user-entered regex pattern into canonical `/source/flags` form.
+ * Strips `/.../flags` wrapping if the user included it, so `new RegExp` does
+ * not double-escape the slashes on save.
+ *
+ * @param {string} pattern - Raw pattern string from the form.
+ * @returns {?string} Canonical `/source/flags` string, or null when empty.
+ */
+export const normalizeRegexPattern = pattern => {
+ if (!pattern) return null;
+ const match = pattern.match(/^\/(.+)\/([gimsuy]*)$/);
+ const source = match ? match[1] : pattern;
+ const flags = match ? match[2] : '';
+ return new RegExp(source, flags).toString();
+};
+
/**
* Checks if a string is a valid slug (letters, numbers, hyphens only, no spaces or other symbols).
* @param {string} value - The slug to validate.
diff --git a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
index a685cb0da..12b84085c 100644
--- a/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
+++ b/app/javascript/shared/helpers/specs/MessageFormatter.spec.js
@@ -33,6 +33,13 @@ describe('#MessageFormatter', () => {
tool
`
);
});
+
+ it('should not render a setext heading when text is followed by "--"', () => {
+ const message = 'hy\n\n\\\n\\-\\-\n\nHello there';
+ const result = new MessageFormatter(message).formattedMessage;
+ expect(result).not.toMatch('');
+ expect(result).not.toMatch('');
+ });
});
describe('content with image and has "cw_image_height" query at the end of URL', () => {
diff --git a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js
index 4546b43a8..62c8f8bee 100644
--- a/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js
+++ b/app/javascript/shared/helpers/specs/ValidatorsHelper.spec.js
@@ -9,6 +9,7 @@ import {
isNumber,
isDomain,
getRegexp,
+ normalizeRegexPattern,
isValidSlug,
} from '../Validators';
@@ -155,6 +156,30 @@ describe('#getRegexp', () => {
});
});
+describe('#normalizeRegexPattern', () => {
+ it('returns null for empty values', () => {
+ expect(normalizeRegexPattern('')).toBeNull();
+ expect(normalizeRegexPattern(null)).toBeNull();
+ expect(normalizeRegexPattern(undefined)).toBeNull();
+ });
+
+ it('canonicalises a bare source', () => {
+ expect(normalizeRegexPattern('^[0-9]+$')).toBe('/^[0-9]+$/');
+ });
+
+ it('strips slash wrapping the user may include', () => {
+ expect(normalizeRegexPattern('/^[0-9]+$/')).toBe('/^[0-9]+$/');
+ });
+
+ it('preserves flags on wrapped input', () => {
+ expect(normalizeRegexPattern('/hello/gi')).toBe('/hello/gi');
+ });
+
+ it('throws for an invalid regex source', () => {
+ expect(() => normalizeRegexPattern('[')).toThrow();
+ });
+});
+
describe('#isValidSlug', () => {
it('should return true for valid slugs', () => {
expect(isValidSlug('abc')).toEqual(true);
diff --git a/app/javascript/shared/helpers/timeHelper.js b/app/javascript/shared/helpers/timeHelper.js
index 07b302776..db5609d89 100644
--- a/app/javascript/shared/helpers/timeHelper.js
+++ b/app/javascript/shared/helpers/timeHelper.js
@@ -94,6 +94,29 @@ export const shortTimestamp = (time, withAgo = false) => {
return convertToShortTime;
};
+/**
+ * Formats a duration in seconds into mm:ss or hh:mm:ss.
+ * @param {number|string} durationInSeconds - Duration in seconds.
+ * @returns {string} Formatted duration string. Empty string for invalid input.
+ */
+export const formatDuration = durationInSeconds => {
+ if (durationInSeconds === null || durationInSeconds === undefined) return '';
+
+ const totalSeconds = Number(durationInSeconds);
+ if (Number.isNaN(totalSeconds) || totalSeconds < 0) return '';
+
+ const hours = Math.floor(totalSeconds / 3600);
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
+ const seconds = totalSeconds % 60;
+
+ const mm = minutes.toString().padStart(2, '0');
+ const ss = seconds.toString().padStart(2, '0');
+ if (hours > 0) {
+ return `${hours.toString().padStart(2, '0')}:${mm}:${ss}`;
+ }
+ return `${mm}:${ss}`;
+};
+
/**
* Calculates the difference in days between now and a given timestamp.
* @param {Date} now - Current date/time.
diff --git a/app/javascript/widget/components/PreChat/Form.vue b/app/javascript/widget/components/PreChat/Form.vue
index 2868d6c3c..280f08363 100644
--- a/app/javascript/widget/components/PreChat/Form.vue
+++ b/app/javascript/widget/components/PreChat/Form.vue
@@ -140,24 +140,15 @@ export default {
},
},
methods: {
- labelClass(input) {
- const { state } = input.context;
- const hasErrors = state.invalid;
- return !hasErrors ? 'text-n-slate-12' : 'text-n-ruby-10';
- },
inputClass(input) {
const { state, family: classification, type } = input.context;
- const hasErrors = state.invalid;
if (classification === 'box' && type === 'checkbox') {
return '';
}
if (type === 'phoneInput') {
- this.hasErrorInPhoneInput = hasErrors;
+ this.hasErrorInPhoneInput = state.invalid;
}
- if (!hasErrors) {
- return `mt-1 rounded w-full py-2 px-3`;
- }
- return `mt-1 rounded w-full py-2 px-3 error`;
+ return 'mt-1 rounded w-full py-2 px-3';
},
isContactFieldRequired(field) {
return this.preChatFields.find(option => option.name === field).required;
@@ -176,7 +167,12 @@ export default {
return this.formValues[name] || null;
},
getValidation({ type, name, field_type, regex_pattern }) {
- let regex = regex_pattern ? getRegexp(regex_pattern) : null;
+ const regex = regex_pattern ? getRegexp(regex_pattern) : null;
+ // FormKit caches the RegExp and calls .test() across keystrokes, so
+ // drop stateful g/y flags to stop lastIndex mutation flipping validity.
+ const matchRegex = regex
+ ? new RegExp(regex.source, regex.flags.replace(/[gy]/g, ''))
+ : null;
const validations = {
emailAddress: 'email',
phoneNumber: ['startsWithPlus', 'isValidPhoneNumber'],
@@ -186,27 +182,32 @@ export default {
select: null,
number: null,
checkbox: false,
- contact_attribute: regex ? [['matches', regex]] : null,
- conversation_attribute: regex ? [['matches', regex]] : null,
+ contact_attribute: matchRegex ? [['matches', matchRegex]] : null,
+ conversation_attribute: matchRegex ? [['matches', matchRegex]] : null,
};
const validationKeys = Object.keys(validations);
const isRequired = this.isContactFieldRequired(name);
- const validation = isRequired ? ['required'] : ['optional'];
+ const baseRules = isRequired ? [['required']] : [['optional']];
if (
- validationKeys.includes(name) ||
- validationKeys.includes(type) ||
- validationKeys.includes(field_type)
+ !validationKeys.includes(name) &&
+ !validationKeys.includes(type) &&
+ !validationKeys.includes(field_type)
) {
- const validationType =
- validations[type] || validations[name] || validations[field_type];
- const allValidations = validationType
- ? validation.concat(validationType)
- : validation;
- return allValidations.join('|');
+ return '';
}
- return '';
+ const validationType =
+ validations[type] || validations[name] || validations[field_type];
+ if (!validationType) return baseRules;
+
+ // Normalise into array-of-arrays so RegExp objects in `['matches', regex]`
+ // survive without being stringified by FormKit.
+ const extraRules = Array.isArray(validationType)
+ ? validationType.map(rule => (Array.isArray(rule) ? rule : [rule]))
+ : [[validationType]];
+
+ return baseRules.concat(extraRules);
},
findFieldType(type) {
if (type === 'link') {
@@ -283,7 +284,7 @@ export default {
}
: undefined
"
- :label-class="context => `text-sm font-medium ${labelClass(context)}`"
+ label-class="text-sm font-medium text-n-slate-12"
:input-class="context => inputClass(context)"
:validation-messages="{
startsWithPlus: $t(
@@ -302,7 +303,7 @@ export default {
v-if="!hasActiveCampaign"
name="message"
type="textarea"
- :label-class="context => `text-sm font-medium ${labelClass(context)}`"
+ label-class="text-sm font-medium text-n-slate-12"
:input-class="context => inputClass(context)"
:label="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.LABEL')"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.MESSAGE.PLACEHOLDER')"
@@ -330,16 +331,21 @@ export default {
@apply mt-2;
.formkit-inner {
- input.error,
- textarea.error,
- select.error {
- @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9;
- }
-
input[type='checkbox'] {
@apply size-4 outline-none;
}
}
+
+ &[data-invalid] {
+ .formkit-label {
+ @apply text-n-ruby-10;
+ }
+ .formkit-inner input,
+ .formkit-inner textarea,
+ .formkit-inner select {
+ @apply outline-n-ruby-8 dark:outline-n-ruby-8 hover:outline-n-ruby-9 dark:hover:outline-n-ruby-9 focus:outline-n-ruby-9 dark:focus:outline-n-ruby-9;
+ }
+ }
}
[data-invalid] .formkit-message {
diff --git a/app/jobs/internal/trigger_hourly_scheduled_items_job.rb b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb
new file mode 100644
index 000000000..f870d742b
--- /dev/null
+++ b/app/jobs/internal/trigger_hourly_scheduled_items_job.rb
@@ -0,0 +1,7 @@
+class Internal::TriggerHourlyScheduledItemsJob < ApplicationJob
+ queue_as :scheduled_jobs
+
+ def perform; end
+end
+
+Internal::TriggerHourlyScheduledItemsJob.prepend_mod_with('Internal::TriggerHourlyScheduledItemsJob')
diff --git a/app/models/concerns/liquidable.rb b/app/models/concerns/liquidable.rb
index 8a90f5f9f..8ef8064e7 100644
--- a/app/models/concerns/liquidable.rb
+++ b/app/models/concerns/liquidable.rb
@@ -11,7 +11,7 @@ module Liquidable
def message_drops
{
'contact' => ContactDrop.new(conversation.contact),
- 'agent' => UserDrop.new(sender),
+ 'agent' => UserDrop.new(sender || conversation.assignee),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(inbox),
'account' => AccountDrop.new(conversation.account)
diff --git a/app/services/imap/base_fetch_email_service.rb b/app/services/imap/base_fetch_email_service.rb
index 09332092c..17114f516 100644
--- a/app/services/imap/base_fetch_email_service.rb
+++ b/app/services/imap/base_fetch_email_service.rb
@@ -1,6 +1,8 @@
require 'net/imap'
class Imap::BaseFetchEmailService
+ MAX_MESSAGES_PER_SYNC = 500
+
pattr_initialize [:channel!, :interval]
def fetch_emails
@@ -77,27 +79,49 @@ class Imap::BaseFetchEmailService
Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching mails from #{channel.email}, found #{seq_nums.length}."
message_ids_with_seq = []
- seq_nums.each_slice(10).each do |batch|
- # Fetch only message-id only without mail body or contents.
- batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
-
- # .fetch returns an array of Net::IMAP::FetchData or nil
- # (instead of an empty array) if there is no matching message.
- # Check
- if batch_message_ids.blank?
- Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
- next
- end
-
- batch_message_ids.each do |data|
- message_id = build_mail_from_string(data.attr['BODY[HEADER]']).message_id
- message_ids_with_seq.push([data.seqno, message_id])
+ seq_nums.each_slice(MAX_MESSAGES_PER_SYNC).each do |batch|
+ append_message_ids_for_batch(batch, message_ids_with_seq)
+ if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
+ Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Reached MAX_MESSAGES_PER_SYNC=#{MAX_MESSAGES_PER_SYNC} for #{channel.email}, stopping sync."
+ break
end
end
message_ids_with_seq
end
+ def append_message_ids_for_batch(batch, message_ids_with_seq)
+ # Fetch only message-id only without mail body or contents.
+ batch_message_ids = imap_client.fetch(batch, 'BODY.PEEK[HEADER]')
+ Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch for #{channel.email}. Found #{batch_message_ids&.length} messages."
+
+ # .fetch returns an array of Net::IMAP::FetchData or nil
+ # (instead of an empty array) if there is no matching message.
+ if batch_message_ids.blank?
+ Rails.logger.info "[IMAP::FETCH_EMAIL_SERVICE] Fetching the batch failed for #{channel.email}."
+ return
+ end
+
+ batch_message_ids.each do |data|
+ entry = build_message_id_entry(data)
+ next if entry.nil?
+
+ message_ids_with_seq.push(entry)
+ break if message_ids_with_seq.length >= MAX_MESSAGES_PER_SYNC
+ end
+ end
+
+ def build_message_id_entry(data)
+ mail = build_mail_from_string(data.attr['BODY[HEADER]'])
+ return nil if MailPresenter.new(mail, channel.account).notification_email_from_chatwoot?
+
+ message_id = mail.message_id
+ return nil if message_id.blank?
+ return nil if email_already_present?(channel, message_id)
+
+ [data.seqno, message_id]
+ end
+
# Sends a SEARCH command to search the mailbox for messages that were
# created between yesterday (or given date) and today and returns message sequence numbers.
# Return
diff --git a/config/features.yml b/config/features.yml
index f16199932..92d004f81 100644
--- a/config/features.yml
+++ b/config/features.yml
@@ -144,10 +144,11 @@
- name: chatwoot_v4
display_name: Chatwoot V4
enabled: true
-- name: report_v4
- display_name: Report V4
+- name: captain_v1_action_classifier
+ display_name: Captain V1 Action Classifier
enabled: false
- deprecated: true
+ premium: true
+ chatwoot_internal: true
- name: contact_chatwoot_support_team
display_name: Contact Chatwoot Support Team
enabled: true
diff --git a/config/initializers/facebook_messenger.rb b/config/initializers/facebook_messenger.rb
index f93829e65..047715cfb 100644
--- a/config/initializers/facebook_messenger.rb
+++ b/config/initializers/facebook_messenger.rb
@@ -1,11 +1,13 @@
# ref: https://github.com/jgorset/facebook-messenger#make-a-configuration-provider
class ChatwootFbProvider < Facebook::Messenger::Configuration::Providers::Base
+ CHANNEL_APP_SECRET_KEYS = %w[app_secret app_secret_key client_secret api_secret].freeze
+
def valid_verify_token?(_verify_token)
GlobalConfigService.load('FB_VERIFY_TOKEN', '')
end
- def app_secret_for(_page_id)
- GlobalConfigService.load('FB_APP_SECRET', '')
+ def app_secret_for(page_id)
+ channel_app_secret_for(page_id).presence || GlobalConfigService.load('FB_APP_SECRET', '')
end
def access_token_for(page_id)
@@ -14,6 +16,27 @@ class ChatwootFbProvider < Facebook::Messenger::Configuration::Providers::Base
private
+ def channel_app_secret_for(page_id)
+ channel = Channel::FacebookPage.where(page_id: page_id).last
+ return if channel.blank?
+
+ channel_app_secret_candidates(channel).first
+ end
+
+ def channel_app_secret_candidates(channel)
+ secrets = []
+ secrets << channel.app_secret if channel.respond_to?(:app_secret)
+ secrets.concat(provider_config_app_secrets(channel))
+ secrets.compact_blank.uniq
+ end
+
+ def provider_config_app_secrets(channel)
+ return [] unless channel.respond_to?(:provider_config)
+
+ provider_config = channel.provider_config.to_h.with_indifferent_access
+ CHANNEL_APP_SECRET_KEYS.filter_map { |key| provider_config[key].presence }
+ end
+
def bot
Chatwoot::Bot
end
diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb
index 60596156c..7b78b466a 100644
--- a/config/initializers/sidekiq.rb
+++ b/config/initializers/sidekiq.rb
@@ -40,11 +40,6 @@ Rails.application.reloader.to_prepare do
if File.exist?(schedule_file) && Sidekiq.server?
schedule = YAML.load_file(schedule_file)
- # Merge enterprise-only cron entries when running an enterprise build.
- # Mirrors the conditional-load pattern already used for enterprise initializers.
- enterprise_schedule_file = Rails.root.join('enterprise/config/schedule.yml')
- schedule.merge!(YAML.load_file(enterprise_schedule_file)) if ChatwootApp.enterprise? && enterprise_schedule_file.exist?
-
# Cron entries removed from schedule.yml but possibly still in Redis
# with source:'dynamic' (predating the source tag). load_from_hash!
# only cleans up source:'schedule' entries, so these need explicit removal.
diff --git a/config/installation_config.yml b/config/installation_config.yml
index 884dd2e58..e374d0948 100644
--- a/config/installation_config.yml
+++ b/config/installation_config.yml
@@ -209,6 +209,12 @@
description: 'The limits for the Captain AI service for different plans'
value:
type: code
+- name: CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS
+ display_title: 'Captain Document Auto Sync Intervals'
+ description: 'JSON map of plan-wise Captain document auto-sync intervals in hours. Use null to disable auto-sync for a plan.'
+ value:
+ locked: false
+ type: code
# End of Captain Config
# ------- Context.dev Config ------- #
diff --git a/config/locales/en.yml b/config/locales/en.yml
index c225392a4..56b552d66 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -64,6 +64,8 @@ en:
email_already_exists: 'You have already signed up for an account with %{email}'
invalid_params: 'Invalid, please check the signup paramters and try again'
failed: Signup failed
+ voice:
+ call_already_accepted: '%{agent_name} is already handling the call.'
assignment_policy:
not_found: Assignment policy not found
attachments:
diff --git a/config/routes.rb b/config/routes.rb
index eff17e714..c1a34bc85 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -607,6 +607,7 @@ Rails.application.routes.draw do
post 'voice/call/:phone', to: 'voice#call_twiml', as: :voice_call
post 'voice/status/:phone', to: 'voice#status', as: :voice_status
post 'voice/conference_status/:phone', to: 'voice#conference_status', as: :voice_conference_status
+ post 'voice/recording_status/:phone', to: 'voice#recording_status', as: :voice_recording_status
end
end
diff --git a/config/schedule.yml b/config/schedule.yml
index f1054ad68..f5e335ba3 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -14,6 +14,12 @@ trigger_scheduled_items_job:
class: 'TriggerScheduledItemsJob'
queue: scheduled_jobs
+# executed hourly for scheduled jobs that do not need minute-level cadence
+trigger_hourly_scheduled_items_job:
+ cron: '0 * * * *'
+ class: 'Internal::TriggerHourlyScheduledItemsJob'
+ queue: scheduled_jobs
+
# executed At every minute..
trigger_imap_email_inboxes_job:
cron: '*/1 * * * *'
diff --git a/db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb b/db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb
new file mode 100644
index 000000000..8f7056ba7
--- /dev/null
+++ b/db/migrate/20260430114500_repurpose_report_v4_flag_for_captain_v1_action_classifier.rb
@@ -0,0 +1,15 @@
+class RepurposeReportV4FlagForCaptainV1ActionClassifier < ActiveRecord::Migration[7.1]
+ def up
+ Account.feature_captain_v1_action_classifier.find_each(batch_size: 100) do |account|
+ account.disable_features(:captain_v1_action_classifier)
+ account.save!(validate: false)
+ end
+
+ config = InstallationConfig.find_by(name: 'ACCOUNT_LEVEL_FEATURE_DEFAULTS')
+ return if config&.value.blank?
+
+ config.value = config.value.reject { |feature| feature['name'] == 'report_v4' }
+ config.save!
+ GlobalConfig.clear_cache
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 4ff250ada..9ce734ba7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_04_28_120000) do
+ActiveRecord::Schema[7.1].define(version: 2026_04_30_114500) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
diff --git a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
index 07cec861d..1123699d8 100644
--- a/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
+++ b/enterprise/app/controllers/api/v1/accounts/conference_controller.rb
@@ -1,5 +1,6 @@
class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseController
before_action :set_voice_inbox_for_conference
+ rescue_from CustomExceptions::CallAlreadyAccepted, with: :render_call_already_accepted
def token
render json: Voice::Provider::Twilio::TokenService.new(
@@ -54,4 +55,8 @@ class Api::V1::Accounts::ConferenceController < Api::V1::Accounts::BaseControlle
authorize conversation, :show?
conversation
end
+
+ def render_call_already_accepted(error)
+ render json: { error: error.message }, status: :conflict
+ end
end
diff --git a/enterprise/app/controllers/twilio/voice_controller.rb b/enterprise/app/controllers/twilio/voice_controller.rb
index 4a5c94f62..eb29c00bd 100644
--- a/enterprise/app/controllers/twilio/voice_controller.rb
+++ b/enterprise/app/controllers/twilio/voice_controller.rb
@@ -38,6 +38,7 @@ class Twilio::VoiceController < ApplicationController
end
call = find_call_for_conference!(params[:FriendlyName], twilio_call_sid)
+ persist_twilio_conference_sid!(call, params[:ConferenceSid])
Voice::Conference::Manager.new(
call: call,
@@ -48,6 +49,15 @@ class Twilio::VoiceController < ApplicationController
head :no_content
end
+ def recording_status
+ Voice::RecordingStatusService.new(
+ account: current_account,
+ payload: params.to_unsafe_h
+ ).perform
+
+ head :no_content
+ end
+
private
def twilio_call_sid
@@ -124,6 +134,10 @@ class Twilio::VoiceController < ApplicationController
conference_sid,
start_conference_on_enter: agent_leg?(twilio_from),
end_conference_on_exit: false,
+ record: 'record-from-start',
+ recording_status_callback: recording_status_callback_url,
+ recording_status_callback_event: 'completed',
+ recording_status_callback_method: 'POST',
status_callback: conference_status_callback_url,
status_callback_event: 'start end join leave',
status_callback_method: 'POST',
@@ -151,12 +165,27 @@ class Twilio::VoiceController < ApplicationController
Rails.application.routes.url_helpers.twilio_voice_conference_status_url(phone: phone_digits)
end
+ def recording_status_callback_url
+ phone_digits = inbox_channel.phone_number.delete_prefix('+')
+ Rails.application.routes.url_helpers.twilio_voice_recording_status_url(phone: phone_digits)
+ end
+
def find_call_for_conference!(friendly_name, call_sid)
name = friendly_name.to_s
call = inbox_calls.by_conference_sid(name).first if name.present?
call || inbox_calls.find_by!(provider_call_id: call_sid)
end
+ # Twilio's recording webhook only sends its internal ConferenceSid (CF...),
+ # not our FriendlyName. Persist Twilio's id the first time we see it on a
+ # conference event so the recording lookup can match later.
+ def persist_twilio_conference_sid!(call, sid)
+ return if sid.blank?
+ return if call.twilio_conference_sid == sid
+
+ call.update!(twilio_conference_sid: sid)
+ end
+
def set_inbox!
digits = params[:phone].to_s.gsub(/\D/, '')
phone_number = "+#{digits}"
diff --git a/enterprise/app/helpers/captain/chat_response_helper.rb b/enterprise/app/helpers/captain/chat_response_helper.rb
index bfb8adc11..e8ac1044c 100644
--- a/enterprise/app/helpers/captain/chat_response_helper.rb
+++ b/enterprise/app/helpers/captain/chat_response_helper.rb
@@ -35,7 +35,10 @@ module Captain::ChatResponseHelper
def credit_used_for_response?(parsed_response)
response = parsed_response['response']
- response.present? && response != 'conversation_handoff'
+
+ # The classifier can still decide to hand off after this trace is written.
+ # Actual response usage is charged later in ResponseBuilderJob, so billing stays correct.
+ response.present? && response != 'conversation_handoff' && parsed_response['action'] != 'handoff'
end
def captain_v1_assistant?
diff --git a/enterprise/app/jobs/captain/conversation/response_builder_job.rb b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
index 5e7c5b3c2..5050f11b2 100644
--- a/enterprise/app/jobs/captain/conversation/response_builder_job.rb
+++ b/enterprise/app/jobs/captain/conversation/response_builder_job.rb
@@ -1,4 +1,6 @@
class Captain::Conversation::ResponseBuilderJob < ApplicationJob
+ include Captain::Conversation::V1ActionClassifier
+
MAX_MESSAGE_LENGTH = 10_000
retry_on ActiveStorage::FileNotFoundError, attempts: 3, wait: 2.seconds
retry_on Faraday::BadRequestError, attempts: 3, wait: 2.seconds
@@ -31,9 +33,11 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
delegate :account, :inbox, to: :@conversation
def generate_and_process_response
+ message_history = collect_previous_messages
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation: @conversation).generate_response(
- message_history: collect_previous_messages
+ message_history: message_history
)
+ classify_v1_response_action(message_history) if conversation_pending?
process_response
end
@@ -102,6 +106,14 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
end
def v1_handoff_requested?
+ legacy_v1_handoff_token? || classifier_v1_handoff_requested?
+ end
+
+ def classifier_v1_handoff_requested?
+ @response['action'] == 'handoff'
+ end
+
+ def legacy_v1_handoff_token?
@response['response'] == 'conversation_handoff'
end
@@ -111,8 +123,13 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def process_v1_handoff
I18n.with_locale(@assistant.account.locale) do
+ Rails.logger.info(
+ "[CAPTAIN][ResponseBuilderJob] V1 handoff requested for account=#{account.id} conversation=#{@conversation.display_id} " \
+ "source=#{@response&.dig('action_source') || 'legacy'} reason=#{@response&.dig('action_reason')}"
+ )
create_handoff_message
@conversation.bot_handoff!
+ report_v1_handoff_not_executed if conversation_pending?
send_out_of_office_message_if_applicable
end
end
@@ -166,6 +183,9 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
def handle_error(error)
log_error(error)
+ @response ||= {}
+ @response['action_source'] ||= 'error'
+ @response['action_reason'] ||= error_action_reason(error)
process_v1_handoff if conversation_pending?
true
end
@@ -174,10 +194,23 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
ChatwootExceptionTracker.new(error, account: account).capture_exception
end
+ def error_action_reason(error)
+ error.class.name.underscore.tr('/', '_')
+ end
+
def captain_v2_enabled?
account.feature_enabled?('captain_integration_v2')
end
+ def report_v1_handoff_not_executed
+ error = StandardError.new("Captain V1 handoff requested but conversation #{@conversation.display_id} is still pending")
+ ChatwootExceptionTracker.new(error, account: account).capture_exception
+ Rails.logger.error(
+ "[CAPTAIN][ResponseBuilderJob] V1 handoff requested but not executed for account=#{account.id} " \
+ "conversation=#{@conversation.display_id}"
+ )
+ end
+
def conversation_pending?
status = Conversation.uncached { Conversation.where(id: @conversation.id).pick(:status) }
status == 'pending' || status == Conversation.statuses[:pending]
diff --git a/enterprise/app/jobs/captain/conversation/v1_action_classifier.rb b/enterprise/app/jobs/captain/conversation/v1_action_classifier.rb
new file mode 100644
index 000000000..9d2010b79
--- /dev/null
+++ b/enterprise/app/jobs/captain/conversation/v1_action_classifier.rb
@@ -0,0 +1,57 @@
+module Captain::Conversation::V1ActionClassifier
+ private
+
+ def v1_action_classifier_enabled?
+ account.feature_enabled?('captain_v1_action_classifier')
+ end
+
+ def classify_v1_response_action(message_history)
+ return unless v1_action_classifier_enabled?
+ return if legacy_v1_handoff_token?
+
+ classification = Captain::Llm::AssistantActionClassifierService.new(
+ assistant: @assistant,
+ conversation: @conversation
+ ).classify(message_history: message_history, assistant_response: @response['response'])
+
+ apply_v1_action_classification(classification)
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: account).capture_exception
+ Rails.logger.warn(
+ "[CAPTAIN][ResponseBuilderJob] V1 action classifier failed for account=#{account.id} " \
+ "conversation=#{@conversation.display_id}: #{e.class.name}: #{e.message}"
+ )
+ end
+
+ def apply_v1_action_classification(classification)
+ action = classification['action']
+ return log_invalid_v1_action_classification(classification) unless valid_v1_action_classification?(action)
+
+ @response.merge!(
+ 'action' => action,
+ 'action_reason' => classification['action_reason'],
+ 'action_source' => 'classifier',
+ 'action_classifier_model' => classification['model']
+ )
+
+ log_v1_action_classification(action, classification)
+ end
+
+ def log_v1_action_classification(action, classification)
+ Rails.logger.info(
+ "[CAPTAIN][ResponseBuilderJob] V1 action classifier account=#{account.id} conversation=#{@conversation.display_id} " \
+ "action=#{action} reason=#{classification['action_reason']} model=#{classification['model']}"
+ )
+ end
+
+ def valid_v1_action_classification?(action)
+ Captain::AssistantActionSchema::ACTIONS.include?(action)
+ end
+
+ def log_invalid_v1_action_classification(classification)
+ Rails.logger.warn(
+ '[CAPTAIN][ResponseBuilderJob] V1 action classifier returned invalid action; falling back to assistant response ' \
+ "for account=#{account.id} conversation=#{@conversation.display_id}: #{classification['error'] || classification['raw_response']}"
+ )
+ end
+end
diff --git a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
index ab72e9b4a..3c2c55132 100644
--- a/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
+++ b/enterprise/app/jobs/captain/documents/schedule_syncs_job.rb
@@ -7,6 +7,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
def perform
@remaining_global_capacity = GLOBAL_HOURLY_CAP
+ sync_intervals = Enterprise::Account.captain_document_sync_intervals
stats = { accounts_scanned: 0, accounts_enabled: 0, accounts_scheduled: 0, documents_enqueued: 0 }
Account.joins(:captain_documents).distinct.find_each(batch_size: 100) do |account|
@@ -16,7 +17,7 @@ class Captain::Documents::ScheduleSyncsJob < ApplicationJob
next unless account.feature_enabled?('captain_document_auto_sync')
stats[:accounts_enabled] += 1
- interval = account.captain_document_sync_interval
+ interval = account.captain_document_sync_interval(sync_intervals)
next unless interval
stats[:accounts_scheduled] += 1
diff --git a/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb
new file mode 100644
index 000000000..9d3baa2d6
--- /dev/null
+++ b/enterprise/app/jobs/enterprise/internal/trigger_hourly_scheduled_items_job.rb
@@ -0,0 +1,7 @@
+module Enterprise::Internal::TriggerHourlyScheduledItemsJob
+ def perform
+ super
+
+ Captain::Documents::ScheduleSyncsJob.perform_later
+ end
+end
diff --git a/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb b/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb
new file mode 100644
index 000000000..26b849d04
--- /dev/null
+++ b/enterprise/app/jobs/voice/provider/twilio/recording_attachment_job.rb
@@ -0,0 +1,17 @@
+class Voice::Provider::Twilio::RecordingAttachmentJob < ApplicationJob
+ queue_as :low
+
+ retry_on Down::Error, wait: 5.seconds, attempts: 3
+
+ def perform(call_id, recording_sid, recording_url, recording_duration = nil)
+ call = Call.find_by(id: call_id)
+ return if call.blank?
+
+ Voice::Provider::Twilio::RecordingAttachmentService.new(
+ call: call,
+ recording_sid: recording_sid,
+ recording_url: recording_url,
+ recording_duration: recording_duration
+ ).perform
+ end
+end
diff --git a/enterprise/app/models/call.rb b/enterprise/app/models/call.rb
index 51c7ef5e9..b0d8bc3d1 100644
--- a/enterprise/app/models/call.rb
+++ b/enterprise/app/models/call.rb
@@ -34,7 +34,7 @@ class Call < ApplicationRecord
# Statuses where the call is finished and won't change again
TERMINAL_STATUSES = %w[completed no_answer failed].freeze
- store_accessor :meta, :conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
+ store_accessor :meta, :conference_sid, :twilio_conference_sid, :recording_sid, :parent_call_sid, :initiated_at, :ended_at
# Frontend voice bubbles/stores expect inbound/outbound string values
DISPLAY_DIRECTION = { 'incoming' => 'inbound', 'outgoing' => 'outbound' }.freeze
@@ -60,6 +60,7 @@ class Call < ApplicationRecord
scope :active, -> { where.not(status: TERMINAL_STATUSES) }
scope :by_conference_sid, ->(sid) { where("meta->>'conference_sid' = ?", sid) }
+ scope :by_twilio_conference_sid, ->(sid) { where("meta->>'twilio_conference_sid' = ?", sid) }
def self.find_by_provider_call_id(provider, sid)
find_by(provider: provider, provider_call_id: sid)
@@ -119,6 +120,7 @@ class Call < ApplicationRecord
duration_seconds: duration_seconds,
conference_sid: conference_sid,
accepted_by_agent_id: accepted_by_agent_id,
+ accepted_by_agent_name: accepted_by_agent&.available_name,
started_at: started_at&.to_i,
ended_at: ended_at,
from_number: from_number,
diff --git a/enterprise/app/models/enterprise/account.rb b/enterprise/app/models/enterprise/account.rb
index e8828cc4c..9b451a23c 100644
--- a/enterprise/app/models/enterprise/account.rb
+++ b/enterprise/app/models/enterprise/account.rb
@@ -1,10 +1,22 @@
module Enterprise::Account
- CAPTAIN_SYNC_INTERVALS = {
- 'hacker' => nil,
- 'startups' => 7.days,
- 'business' => 1.day,
- 'enterprise' => 6.hours
- }.freeze
+ class << self
+ def captain_document_sync_intervals
+ parse_captain_document_sync_intervals(InstallationConfig.find_by(name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS')&.value)
+ end
+
+ private
+
+ def parse_captain_document_sync_intervals(configured_intervals)
+ return {} if configured_intervals.blank?
+
+ parsed_intervals = configured_intervals.is_a?(String) ? JSON.parse(configured_intervals) : configured_intervals
+ return {} unless parsed_intervals.is_a?(Hash)
+
+ parsed_intervals.transform_keys { |plan| plan.to_s.downcase }
+ rescue JSON::ParserError
+ {}
+ end
+ end
# TODO: Remove this when we upgrade administrate gem to the latest version
# this is a temporary method since current administrate doesn't support virtual attributes
@@ -41,12 +53,15 @@ module Enterprise::Account
custom_attributes.delete('marked_for_deletion_at') && custom_attributes.delete('marked_for_deletion_reason') && save
end
- def captain_document_sync_interval
+ def captain_document_sync_interval(sync_intervals = Enterprise::Account.captain_document_sync_intervals)
plan = custom_attributes['plan_name']
plan = 'enterprise' if plan.blank? && ChatwootApp.self_hosted_enterprise?
return nil if plan.blank?
- CAPTAIN_SYNC_INTERVALS[plan.downcase]
+ interval_hours = sync_intervals[plan.downcase]
+ return nil unless interval_hours.is_a?(Integer) && interval_hours.positive?
+
+ interval_hours.hours
end
def saml_enabled?
diff --git a/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb b/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
new file mode 100644
index 000000000..7c0f1e91e
--- /dev/null
+++ b/enterprise/app/services/captain/llm/assistant_action_classifier_service.rb
@@ -0,0 +1,148 @@
+class Captain::Llm::AssistantActionClassifierService < Llm::BaseAiService
+ include Integrations::LlmInstrumentation
+
+ MAX_CONTEXT_MESSAGES = 10
+
+ def initialize(assistant:, conversation:)
+ super()
+ @assistant = assistant
+ @conversation = conversation
+ @temperature = 0.0
+ end
+
+ def classify(message_history:, assistant_response:)
+ user_prompt = classification_user_prompt(
+ message_history: message_history,
+ assistant_response: assistant_response
+ )
+
+ response = instrument_llm_call(instrumentation_params(user_prompt)) do
+ chat(model: @model, temperature: @temperature)
+ .with_schema(Captain::AssistantActionSchema)
+ .with_instructions(system_prompt)
+ .ask(user_prompt)
+ end
+
+ parsed = parse_response(response.content)
+ normalize_response(parsed, response.content)
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e, account: @conversation.account).capture_exception
+ Rails.logger.warn(
+ "[CAPTAIN][AssistantActionClassifier] Failed for conversation #{@conversation.display_id}: #{e.class.name}: #{e.message}"
+ )
+ { 'action' => nil, 'action_reason' => nil, 'error' => e.message, 'model' => @model }
+ end
+
+ private
+
+ def classification_user_prompt(message_history:, assistant_response:)
+ <<~PROMPT
+
+ #{@assistant.config['instructions']}
+
+
+
+ #{format_conversation_context(message_history)}
+
+
+
+ #{assistant_response}
+
+ PROMPT
+ end
+
+ def normalize_messages(message_history)
+ message_history.filter_map do |message|
+ role = message[:role] || message['role']
+ next if role.blank?
+
+ { role: role.to_s, content: normalize_content(message[:content] || message['content']) }
+ end
+ end
+
+ def normalize_content(content)
+ return content if content.is_a?(String)
+ return content.filter_map { |part| part[:text] || part['text'] if text_part?(part) }.join("\n") if content.is_a?(Array)
+
+ content.to_s
+ end
+
+ def text_part?(part)
+ return false unless part.is_a?(Hash)
+
+ (part[:type] || part['type']).to_s == 'text'
+ end
+
+ def format_conversation_context(messages)
+ normalize_messages(messages).last(MAX_CONTEXT_MESSAGES).filter_map do |message|
+ content = message[:content].to_s.strip
+ next if content.blank?
+
+ "#{role_label(message[:role])}: #{content}"
+ end.join("\n")
+ end
+
+ def role_label(role)
+ return 'User' if role == 'user'
+ return 'Assistant' if role == 'assistant'
+
+ role.to_s.titleize
+ end
+
+ def parse_response(content)
+ return content if content.is_a?(Hash)
+
+ JSON.parse(sanitize_json_response(content))
+ rescue JSON::ParserError, TypeError
+ {}
+ end
+
+ def normalize_response(parsed, raw_content)
+ action = parsed['action'].to_s
+ reason = parsed['action_reason'].to_s
+ return invalid_response(raw_content) unless Captain::AssistantActionSchema::ACTIONS.include?(action)
+
+ {
+ 'action' => action,
+ 'action_reason' => reason.presence,
+ 'raw_response' => raw_content,
+ 'model' => @model
+ }
+ end
+
+ def invalid_response(raw_content)
+ {
+ 'action' => nil,
+ 'action_reason' => nil,
+ 'raw_response' => raw_content,
+ 'error' => 'invalid_classifier_response',
+ 'model' => @model
+ }
+ end
+
+ def instrumentation_params(user_prompt)
+ {
+ span_name: 'llm.captain.assistant_action_classifier',
+ model: @model,
+ temperature: @temperature,
+ account_id: @conversation.account_id,
+ conversation_id: @conversation.display_id,
+ feature_name: 'assistant_action_classifier',
+ messages: [
+ { role: 'system', content: system_prompt },
+ { role: 'user', content: user_prompt }
+ ],
+ metadata: {
+ assistant_id: @assistant.id,
+ channel_type: @conversation.inbox&.channel_type,
+ source: 'v1_response_builder'
+ }
+ }
+ end
+
+ def system_prompt
+ Captain::Llm::SystemPromptsService.assistant_action_classifier(
+ has_custom_instructions: @assistant.config['instructions'].present?
+ )
+ end
+end
diff --git a/enterprise/app/services/captain/llm/system_prompts_service.rb b/enterprise/app/services/captain/llm/system_prompts_service.rb
index eb8c334f4..9520330f6 100644
--- a/enterprise/app/services/captain/llm/system_prompts_service.rb
+++ b/enterprise/app/services/captain/llm/system_prompts_service.rb
@@ -93,6 +93,50 @@ class Captain::Llm::SystemPromptsService
SYSTEM_PROMPT_MESSAGE
end
+ def assistant_action_classifier(has_custom_instructions: false)
+ <<~PROMPT
+ You are a routing classifier for a customer-support assistant.
+
+ Decide whether the current conversation should stay with the assistant or be transferred to a human agent now.
+
+ The action field MUST be one of:
+ - "continue": keep the current conversation with the assistant.
+ - "handoff": transfer the current conversation to a human agent now.
+
+ The action_reason field MUST be one of:
+ - "general_product_question"
+ - "missing_docs_bounded_answer"
+ - "clarifying_question_needed"
+ - "collect_required_identifier"
+ - "external_contact_or_lead_routing"
+ - "out_of_scope_bounded_answer"
+ - "explicit_human_request"
+ - "human_offer_accepted"
+ - "account_or_transaction_verification"
+ - "operational_issue_needs_inspection"
+ - "repeated_frustration_or_loop"
+ - "custom_instruction_transfer"
+
+ Use "continue" when:
+ - The user has a general product, pricing, capability, setup, pre-sales, or how-to question.
+ - The assistant can give a bounded answer, ask one useful clarifying question, collect a missing identifier, or share an approved external contact path.
+ - The assistant says someone will contact the user outside this conversation, but the current conversation itself does not need to be transferred now.
+ - The user has not explicitly asked for a human and the assistant is still collecting required details.
+
+ Use "handoff" when:
+ - The user explicitly asks for a human, agent, representative, phone call, callback, or escalation.
+ - The user accepts an offer to speak with a human.
+ - The user has provided enough detail for an account-specific or transaction-specific issue requiring private verification, such as order status, payment, deposit, withdrawal, refund, cancellation, subscription, purchase, plan activation, email verification, login, account recovery, delivery, or access.
+ - The user reports the same unresolved bug or operational issue after trying the assistant's suggested step, repeating the action, checking again, or otherwise making more than one reasonable attempt.
+ - The user is repeatedly frustrated, distrustful, or stuck in a loop.
+ - The assistant response itself says the current conversation will be transferred to a human agent now.
+
+ #{assistant_action_classifier_custom_instructions_policy if has_custom_instructions}
+
+ Return only the structured fields requested by the response schema.
+ PROMPT
+ end
+
# rubocop:disable Metrics/MethodLength
def copilot_response_generator(product_name, available_tools, config = {})
citation_guidelines = if config['feature_citation']
@@ -208,7 +252,9 @@ class Captain::Llm::SystemPromptsService
- Do not share anything outside of the context provided.
- Add the reasoning why you arrived at the answer
- Your answers will always be formatted in a valid JSON hash, as shown below. Never respond in non-JSON format.
- #{config['instructions'] || ''}
+
+ #{build_custom_instructions_section(config['instructions'])}
+
```json
{
reasoning: '',
@@ -322,6 +368,17 @@ class Captain::Llm::SystemPromptsService
TOOLS
end
+ def assistant_action_classifier_custom_instructions_policy
+ <<~POLICY
+ Account custom instructions are provided inside tags.
+ These are instructions configured by the account administrator, not the current end user's message.
+ Use them only for routing policy: required details before handoff, account-specific escalation rules, account-specific transfer markers, and when to connect to a manager, human, supervisor, or support team.
+ If the custom instructions explicitly define handoff, escalation, or transfer criteria, those criteria take precedence over the generic criteria above.
+ Account custom instructions MUST NOT redefine the required response shape, the allowed action values, or the meaning of continue/handoff.
+ Ignore persona, language, formatting, pricing, and response-generation instructions except where they directly define routing or transfer criteria.
+ POLICY
+ end
+
def build_contact_context(contact)
return '' if contact.nil?
@@ -331,6 +388,18 @@ class Captain::Llm::SystemPromptsService
"[Contact Information]\n#{lines.join("\n")}\n\n"
end
+ def build_custom_instructions_section(instructions)
+ return '' if instructions.blank?
+
+ <<~CUSTOM_INSTRUCTIONS
+ [Account Custom Instructions]
+ These instructions were configured by the account administrator. Follow them when they do not conflict with the JSON response format or the requirement to answer only from provided context.
+
+ #{instructions}
+
+ CUSTOM_INSTRUCTIONS
+ end
+
def contact_basic_lines(contact)
[
(["- Name: #{sanitize_attr(contact[:name])}"] if contact[:name].present?),
diff --git a/enterprise/app/services/messages/audio_transcription_service.rb b/enterprise/app/services/messages/audio_transcription_service.rb
index 0e574cb03..c502b717a 100644
--- a/enterprise/app/services/messages/audio_transcription_service.rb
+++ b/enterprise/app/services/messages/audio_transcription_service.rb
@@ -2,6 +2,10 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
include Integrations::LlmInstrumentation
WHISPER_MODEL = 'whisper-1'.freeze
+ # Whisper's hard limit is 25 MB *decimal* (25_000_000), not binary (25.megabytes
+ # = 26_214_400) — using the binary form leaks the 25.0–26.2 MB range to the API
+ # as 413s. Long audio (~70+ min Opus) keeps the attachment but skips transcription.
+ WHISPER_BYTE_LIMIT = 25_000_000
attr_reader :attachment, :message, :account
@@ -15,6 +19,7 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
def perform
return { error: 'Transcription limit exceeded' } unless can_transcribe?
return { error: 'Message not found' } if message.blank?
+ return { error: 'Audio too large for Whisper' } if audio_too_large?
transcriptions = transcribe_audio
Rails.logger.info "Audio transcription successful: #{transcriptions}"
@@ -33,6 +38,13 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
account.usage_limits[:captain][:responses][:current_available].positive?
end
+ def audio_too_large?
+ blob = attachment.file&.blob
+ return false unless blob
+
+ blob.byte_size > WHISPER_BYTE_LIMIT
+ end
+
def fetch_audio_file
blob = attachment.file.blob
temp_dir = Rails.root.join('tmp/uploads/audio-transcriptions')
@@ -63,11 +75,14 @@ class Messages::AudioTranscriptionService< Llm::LegacyBaseOpenAiService
transcribed_text = nil
File.open(temp_file_path, 'rb') do |file|
+ # temperature: 0.0 minimises Whisper's hallucinations on silence /
+ # near-silent audio; non-zero values trigger spiraling repeats like
+ # "Oh, dear. Oh, dear. Oh, dear." — well-documented Whisper behaviour.
response = @client.audio.transcribe(
parameters: {
model: WHISPER_MODEL,
file: file,
- temperature: 0.4
+ temperature: 0.0
}
)
transcribed_text = response['text']
diff --git a/enterprise/app/services/voice/conference/manager.rb b/enterprise/app/services/voice/conference/manager.rb
index 9f34eec75..8be2b656e 100644
--- a/enterprise/app/services/voice/conference/manager.rb
+++ b/enterprise/app/services/voice/conference/manager.rb
@@ -31,10 +31,32 @@ class Voice::Conference::Manager
def join_agent!
user_id = extract_user_id
- call.update!(accepted_by_agent_id: user_id) if user_id
+ claim_for_user!(user_id) if user_id
status_manager.process_status_update('in_progress', timestamp: now)
end
+ # First-join wins; later joins by other agents are silently ignored so the
+ # webhook doesn't stomp the original assignee. User-facing rejection happens
+ # at the API layer.
+ def claim_for_user!(user_id)
+ claimed = false
+ call.with_lock do
+ next if call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user_id
+
+ call.update!(accepted_by_agent_id: user_id) if call.accepted_by_agent_id != user_id
+ claimed = true
+ end
+
+ auto_assign_conversation!(user_id) if claimed
+ end
+
+ def auto_assign_conversation!(user_id)
+ conversation = call.conversation
+ return if conversation.assignee_id.present?
+
+ Conversations::AssignmentService.new(conversation: conversation, assignee_id: user_id).perform
+ end
+
# Parses agent user_id from participant_label. Only returns an id when the
# label's embedded account id matches the call's account — protects against
# a spoofed/cross-account label attaching a foreign user to the call.
diff --git a/enterprise/app/services/voice/provider/twilio/conference_service.rb b/enterprise/app/services/voice/provider/twilio/conference_service.rb
index 4b3a8f0b5..0ee368d00 100644
--- a/enterprise/app/services/voice/provider/twilio/conference_service.rb
+++ b/enterprise/app/services/voice/provider/twilio/conference_service.rb
@@ -9,7 +9,8 @@ class Voice::Provider::Twilio::ConferenceService
end
def mark_agent_joined(user:)
- call.update!(accepted_by_agent: user)
+ claim_call!(user)
+ assign_conversation!(user)
end
def end_conference
@@ -21,4 +22,30 @@ class Voice::Provider::Twilio::ConferenceService
.list(friendly_name: call.conference_sid, status: 'in-progress')
.each { |conf| client.conferences(conf.sid).update(status: 'completed') }
end
+
+ private
+
+ def claim_call!(user)
+ call.with_lock do
+ raise_already_accepted!(call.accepted_by_agent) if claimed_by_other_agent?(user)
+ call.update!(accepted_by_agent: user) if call.accepted_by_agent_id != user.id
+ end
+ end
+
+ def claimed_by_other_agent?(user)
+ call.accepted_by_agent_id.present? && call.accepted_by_agent_id != user.id
+ end
+
+ def raise_already_accepted!(agent)
+ raise CustomExceptions::CallAlreadyAccepted.new(agent_name: agent&.available_name || agent&.name)
+ end
+
+ # Existing assignments win — manual reassignment and pre-call assignment
+ # (e.g., lock_to_single_conversation) shouldn't be stomped on pickup.
+ def assign_conversation!(user)
+ conversation = call.conversation
+ return if conversation.assignee_id.present?
+
+ Conversations::AssignmentService.new(conversation: conversation, assignee_id: user.id).perform
+ end
end
diff --git a/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb
new file mode 100644
index 000000000..05cd3d227
--- /dev/null
+++ b/enterprise/app/services/voice/provider/twilio/recording_attachment_service.rb
@@ -0,0 +1,81 @@
+class Voice::Provider::Twilio::RecordingAttachmentService
+ DEFAULT_FILENAME_EXTENSION = 'wav'.freeze
+ ALLOWED_CONTENT_TYPE_PREFIXES = %w[audio/].freeze
+
+ pattr_initialize [:call!, :recording_sid!, :recording_url!, { recording_duration: nil }]
+
+ def perform
+ return if recording_sid.blank? || recording_url.blank?
+ return if already_attached?
+
+ SafeFetch.fetch(
+ recording_url,
+ http_basic_authentication: [account_sid, auth_token],
+ allowed_content_type_prefixes: ALLOWED_CONTENT_TYPE_PREFIXES
+ ) do |result|
+ persist_recording!(result)
+ end
+
+ # Bump the message updated_at so the message.updated dispatcher rebroadcasts
+ # the embedded Call payload (now with recording_url) to connected clients.
+ call.message&.touch # rubocop:disable Rails/SkipsModelValidations
+ end
+
+ private
+
+ def persist_recording!(result)
+ call.with_lock do
+ next if already_attached?
+
+ attach_recording!(result)
+ call.recording_sid = recording_sid
+ call.duration_seconds ||= normalized_recording_duration
+ call.save!
+ end
+ end
+
+ def already_attached?
+ call.recording.attached? && call.recording_sid.to_s == recording_sid.to_s
+ end
+
+ def attach_recording!(result)
+ call.recording.attach(
+ io: result.tempfile,
+ filename: recording_filename(result),
+ content_type: recording_content_type(result)
+ )
+ end
+
+ def normalized_recording_duration
+ return if recording_duration.blank?
+
+ recording_duration.to_i
+ end
+
+ def recording_filename(result)
+ return result.original_filename if result.original_filename.present?
+
+ "call-recording-#{recording_sid}.#{recording_extension(result)}"
+ end
+
+ def recording_extension(result)
+ content_type = recording_content_type(result)
+ Rack::Mime::MIME_TYPES.invert[content_type].to_s.delete_prefix('.').presence || DEFAULT_FILENAME_EXTENSION
+ end
+
+ def recording_content_type(result)
+ result.content_type.presence || 'audio/wav'
+ end
+
+ def account_sid
+ @account_sid ||= channel.account_sid
+ end
+
+ def auth_token
+ @auth_token ||= channel.auth_token
+ end
+
+ def channel
+ @channel ||= call.inbox.channel
+ end
+end
diff --git a/enterprise/app/services/voice/recording_status_service.rb b/enterprise/app/services/voice/recording_status_service.rb
new file mode 100644
index 000000000..4dbbd44b8
--- /dev/null
+++ b/enterprise/app/services/voice/recording_status_service.rb
@@ -0,0 +1,40 @@
+class Voice::RecordingStatusService
+ pattr_initialize [:account!, { payload: {} }]
+
+ def perform
+ return unless completed_recording?
+ return if conference_sid.blank? || recording_sid.blank? || recording_url.blank?
+
+ call = Call.where(account_id: account.id).by_twilio_conference_sid(conference_sid).first
+ return if call.blank?
+
+ Voice::Provider::Twilio::RecordingAttachmentJob.perform_later(
+ call.id,
+ recording_sid,
+ recording_url,
+ recording_duration
+ )
+ end
+
+ private
+
+ def completed_recording?
+ payload['RecordingStatus'].to_s.casecmp('completed').zero?
+ end
+
+ def conference_sid
+ payload['ConferenceSid'].to_s
+ end
+
+ def recording_sid
+ payload['RecordingSid'].to_s
+ end
+
+ def recording_url
+ payload['RecordingUrl'].to_s
+ end
+
+ def recording_duration
+ payload['RecordingDuration']
+ end
+end
diff --git a/enterprise/config/schedule.yml b/enterprise/config/schedule.yml
deleted file mode 100644
index 05ea11e64..000000000
--- a/enterprise/config/schedule.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-# Enterprise-only Sidekiq cron schedule.
-# Loaded by config/initializers/sidekiq.rb only when ChatwootApp.enterprise? is true.
-# Add cron entries here when the referenced job class lives under enterprise/.
-
-# Captain document auto-sync scheduler
-# Runs hourly, finds due documents based on plan sync intervals
-captain_documents_schedule_syncs_job:
- cron: '0 * * * *'
- class: 'Captain::Documents::ScheduleSyncsJob'
- queue: scheduled_jobs
diff --git a/enterprise/lib/captain/assistant_action_schema.rb b/enterprise/lib/captain/assistant_action_schema.rb
new file mode 100644
index 000000000..e8a8de435
--- /dev/null
+++ b/enterprise/lib/captain/assistant_action_schema.rb
@@ -0,0 +1,20 @@
+class Captain::AssistantActionSchema < RubyLLM::Schema
+ ACTIONS = %w[continue handoff].freeze
+ REASONS = %w[
+ general_product_question
+ missing_docs_bounded_answer
+ clarifying_question_needed
+ collect_required_identifier
+ external_contact_or_lead_routing
+ out_of_scope_bounded_answer
+ explicit_human_request
+ human_offer_accepted
+ account_or_transaction_verification
+ operational_issue_needs_inspection
+ repeated_frustration_or_loop
+ custom_instruction_transfer
+ ].freeze
+
+ string :action, enum: ACTIONS, description: 'Whether to keep the conversation with the assistant or transfer it to a human agent'
+ string :action_reason, enum: REASONS, description: 'The reason for the selected routing action'
+end
diff --git a/lib/custom_exceptions/call_already_accepted.rb b/lib/custom_exceptions/call_already_accepted.rb
new file mode 100644
index 000000000..8a0be27f3
--- /dev/null
+++ b/lib/custom_exceptions/call_already_accepted.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class CustomExceptions::CallAlreadyAccepted < CustomExceptions::Base
+ def message
+ I18n.t('errors.voice.call_already_accepted', agent_name: @data[:agent_name])
+ end
+
+ def http_status
+ 409
+ end
+end
diff --git a/package.json b/package.json
index 8d45183e0..bb5b59ba3 100644
--- a/package.json
+++ b/package.json
@@ -34,7 +34,7 @@
"@amplitude/analytics-browser": "^2.11.10",
"@breezystack/lamejs": "^1.2.7",
"@chatwoot/ninja-keys": "1.2.3",
- "@chatwoot/prosemirror-schema": "1.3.10",
+ "@chatwoot/prosemirror-schema": "1.3.11",
"@chatwoot/utils": "^0.0.52",
"@formkit/core": "^1.7.2",
"@formkit/vue": "^1.7.2",
@@ -121,6 +121,7 @@
"@iconify-json/ri": "^1.2.6",
"@iconify-json/teenyicons": "^1.2.2",
"@intlify/eslint-plugin-vue-i18n": "^3.2.0",
+ "@rollup/plugin-yaml": "^4.1.2",
"@size-limit/file": "^8.2.4",
"@vitest/coverage-v8": "3.0.5",
"@vue/test-utils": "^2.4.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ab9e7b241..2a3aee897 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,8 +26,8 @@ importers:
specifier: 1.2.3
version: 1.2.3
'@chatwoot/prosemirror-schema':
- specifier: 1.3.10
- version: 1.3.10
+ specifier: 1.3.11
+ version: 1.3.11
'@chatwoot/utils':
specifier: ^0.0.52
version: 0.0.52
@@ -281,6 +281,9 @@ importers:
'@intlify/eslint-plugin-vue-i18n':
specifier: ^3.2.0
version: 3.2.0(eslint@8.57.0)
+ '@rollup/plugin-yaml':
+ specifier: ^4.1.2
+ version: 4.1.2(rollup@4.59.0)
'@size-limit/file':
specifier: ^8.2.4
version: 8.2.6(size-limit@8.2.6)
@@ -460,8 +463,8 @@ packages:
'@chatwoot/ninja-keys@1.2.3':
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
- '@chatwoot/prosemirror-schema@1.3.10':
- resolution: {integrity: sha512-MtOXqFPHptFHu/AoIPhQ9TskVXOxOXCgBY/tgAtCAQRut978F7I3QxozQBECBz83ubsoXnBecpNjGNq0OPgONw==}
+ '@chatwoot/prosemirror-schema@1.3.11':
+ resolution: {integrity: sha512-+GptIqY73/EtojrhAKX4UKwZF7NUB47DMzgYxmx8Vu07DLKCGe+8JnbHJnR9oBy8p5sn5VOO1ihNf/4Pg2RzIQ==}
'@chatwoot/utils@0.0.52':
resolution: {integrity: sha512-e57uVqyVW4tj1gql4YJPNMykqMJPkETn5Y9AmHdhc6Y7oxDXfRXBq27fZrrDadLkZdn5RYVCZjfIhXOumyYv2Q==}
@@ -1122,6 +1125,24 @@ packages:
'@rails/ujs@7.1.400':
resolution: {integrity: sha512-YwvXm3BR5tn+VCAKYGycLejMRVZE3Ionj5gFjEeGXCZnI0Rpi+7dKpmyu90kdUY7dRUFpHTdu9zZceEzFLl38w==}
+ '@rollup/plugin-yaml@4.1.2':
+ resolution: {integrity: sha512-RpupciIeZMUqhgFE97ba0s98mOFS7CWzN3EJNhJkqSv9XLlWYtwVdtE6cDw6ASOF/sZVFS7kRJXftaqM2Vakdw==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: '>=4.59.0'
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
+ '@rollup/pluginutils@5.3.0':
+ resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ rollup: '>=4.59.0'
+ peerDependenciesMeta:
+ rollup:
+ optional: true
+
'@rollup/rollup-android-arm-eabi@4.59.0':
resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==}
cpu: [arm]
@@ -3016,8 +3037,8 @@ packages:
resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==}
hasBin: true
- js-yaml@4.1.0:
- resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==}
+ js-yaml@4.1.1:
+ resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdom@20.0.3:
@@ -3551,6 +3572,10 @@ packages:
resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==}
engines: {node: '>=8.6'}
+ picomatch@4.0.4:
+ resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==}
+ engines: {node: '>=12'}
+
pidtree@0.6.0:
resolution: {integrity: sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==}
engines: {node: '>=0.10'}
@@ -4315,6 +4340,10 @@ packages:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
+ tosource@2.0.0-alpha.3:
+ resolution: {integrity: sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==}
+ engines: {node: '>=10'}
+
totalist@3.0.1:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
@@ -4976,7 +5005,7 @@ snapshots:
hotkeys-js: 3.8.7
lit: 2.2.6
- '@chatwoot/prosemirror-schema@1.3.10':
+ '@chatwoot/prosemirror-schema@1.3.11':
dependencies:
markdown-it-sup: 2.0.0
prosemirror-commands: 1.7.1
@@ -5328,7 +5357,7 @@ snapshots:
globals: 13.24.0
ignore: 5.2.4
import-fresh: 3.3.0
- js-yaml: 4.1.0
+ js-yaml: 4.1.1
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -5342,7 +5371,7 @@ snapshots:
globals: 14.0.0
ignore: 5.2.4
import-fresh: 3.3.0
- js-yaml: 4.1.0
+ js-yaml: 4.1.1
minimatch: 3.1.5
strip-json-comments: 3.1.1
transitivePeerDependencies:
@@ -5547,7 +5576,7 @@ snapshots:
ignore: 6.0.2
import-fresh: 3.3.0
is-language-code: 3.1.0
- js-yaml: 4.1.0
+ js-yaml: 4.1.1
json5: 2.2.3
jsonc-eslint-parser: 2.4.0
lodash: 4.17.21
@@ -5689,6 +5718,22 @@ snapshots:
'@rails/ujs@7.1.400': {}
+ '@rollup/plugin-yaml@4.1.2(rollup@4.59.0)':
+ dependencies:
+ '@rollup/pluginutils': 5.3.0(rollup@4.59.0)
+ js-yaml: 4.1.1
+ tosource: 2.0.0-alpha.3
+ optionalDependencies:
+ rollup: 4.59.0
+
+ '@rollup/pluginutils@5.3.0(rollup@4.59.0)':
+ dependencies:
+ '@types/estree': 1.0.8
+ estree-walker: 2.0.2
+ picomatch: 4.0.4
+ optionalDependencies:
+ rollup: 4.59.0
+
'@rollup/rollup-android-arm-eabi@4.59.0':
optional: true
@@ -7184,7 +7229,7 @@ snapshots:
imurmurhash: 0.1.4
is-glob: 4.0.3
is-path-inside: 3.0.3
- js-yaml: 4.1.0
+ js-yaml: 4.1.1
json-stable-stringify-without-jsonify: 1.0.1
levn: 0.4.1
lodash.merge: 4.6.2
@@ -7851,7 +7896,7 @@ snapshots:
argparse: 1.0.10
esprima: 4.0.1
- js-yaml@4.1.0:
+ js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -8421,6 +8466,8 @@ snapshots:
picomatch@2.3.1: {}
+ picomatch@4.0.4: {}
+
pidtree@0.6.0: {}
pify@2.3.0: {}
@@ -8723,7 +8770,7 @@ snapshots:
prosemirror-dropcursor@1.8.1:
dependencies:
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-gapcursor@1.3.2:
@@ -8736,14 +8783,14 @@ snapshots:
prosemirror-history@1.4.1:
dependencies:
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
rope-sequence: 1.3.2
prosemirror-inputrules@1.4.0:
dependencies:
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-keymap@1.2.2:
dependencies:
@@ -8783,7 +8830,7 @@ snapshots:
prosemirror-keymap: 1.2.2
prosemirror-model: 1.22.3
prosemirror-state: 1.4.3
- prosemirror-transform: 1.10.0
+ prosemirror-transform: 1.12.0
prosemirror-view: 1.34.1
prosemirror-transform@1.10.0:
@@ -9308,6 +9355,8 @@ snapshots:
dependencies:
is-number: 7.0.0
+ tosource@2.0.0-alpha.3: {}
+
totalist@3.0.1: {}
tough-cookie@4.1.4:
diff --git a/spec/controllers/webhooks/instagram_controller_spec.rb b/spec/controllers/webhooks/instagram_controller_spec.rb
index c31d65a2b..043d41b93 100644
--- a/spec/controllers/webhooks/instagram_controller_spec.rb
+++ b/spec/controllers/webhooks/instagram_controller_spec.rb
@@ -1,6 +1,25 @@
require 'rails_helper'
RSpec.describe 'Webhooks::InstagramController', type: :request do
+ let(:client_secret) { 'test-instagram-secret' }
+
+ def signature_for(body, secret = client_secret)
+ "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
+ end
+
+ def post_instagram_webhook(body, signature: signature_for(body), env: { INSTAGRAM_APP_SECRET: client_secret })
+ with_modified_env env do
+ post '/webhooks/instagram',
+ params: body,
+ headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature }
+ end
+ end
+
+ before do
+ InstallationConfig.where(name: %w[FB_APP_SECRET IG_VERIFY_TOKEN INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN]).delete_all
+ GlobalConfig.clear_cache
+ end
+
describe 'GET /webhooks/verify' do
it 'returns 401 when valid params are not present' do
get '/webhooks/instagram/verify'
@@ -24,26 +43,62 @@ RSpec.describe 'Webhooks::InstagramController', type: :request do
describe 'POST /webhooks/instagram' do
let!(:dm_params) { build(:instagram_message_create_event).with_indifferent_access }
+ let(:body) { dm_params.merge(object: 'instagram').to_json }
- it 'call the instagram events job with the params' do
+ it 'calls the instagram events job with the params for a valid signature' do
allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
expect(Webhooks::InstagramEventsJob).to receive(:perform_later)
- instagram_params = dm_params.merge(object: 'instagram')
- post '/webhooks/instagram', params: instagram_params
+ post_instagram_webhook(body)
expect(response).to have_http_status(:success)
end
+ it 'accepts webhook payloads signed with the Facebook app secret' do
+ allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
+ expect(Webhooks::InstagramEventsJob).to receive(:perform_later)
+
+ facebook_secret = 'test-facebook-secret'
+ post_instagram_webhook(
+ body,
+ signature: signature_for(body, facebook_secret),
+ env: { FB_APP_SECRET: facebook_secret }
+ )
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns unauthorized when signature is missing' do
+ allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
+
+ with_modified_env INSTAGRAM_APP_SECRET: client_secret do
+ post '/webhooks/instagram',
+ params: body,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later)
+ end
+
+ it 'returns unauthorized when signature is invalid' do
+ allow(Webhooks::InstagramEventsJob).to receive(:perform_later)
+
+ post_instagram_webhook(body, signature: 'sha256=invalid-signature')
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(Webhooks::InstagramEventsJob).not_to have_received(:perform_later)
+ end
+
context 'when processing echo events' do
let!(:echo_params) { build(:instagram_story_mention_event_with_echo).with_indifferent_access }
+ let(:echo_body) { echo_params.merge(object: 'instagram').to_json }
it 'delays processing for echo events by 2 seconds' do
job_double = class_double(Webhooks::InstagramEventsJob)
allow(Webhooks::InstagramEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double)
allow(job_double).to receive(:perform_later)
- instagram_params = echo_params.merge(object: 'instagram')
- post '/webhooks/instagram', params: instagram_params
+ post_instagram_webhook(echo_body)
expect(response).to have_http_status(:success)
expect(Webhooks::InstagramEventsJob).to have_received(:set).with(wait: 2.seconds)
expect(job_double).to have_received(:perform_later)
diff --git a/spec/controllers/webhooks/whatsapp_controller_spec.rb b/spec/controllers/webhooks/whatsapp_controller_spec.rb
index e5fa392b0..05816094b 100644
--- a/spec/controllers/webhooks/whatsapp_controller_spec.rb
+++ b/spec/controllers/webhooks/whatsapp_controller_spec.rb
@@ -2,6 +2,33 @@ require 'rails_helper'
RSpec.describe 'Webhooks::WhatsappController', type: :request do
let(:channel) { create(:channel_whatsapp, provider: 'whatsapp_cloud', sync_templates: false, validate_provider_config: false) }
+ let(:client_secret) { 'test-whatsapp-secret' }
+ let(:body) { { content: 'hello' }.to_json }
+
+ def signature_for(body, secret = client_secret)
+ "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
+ end
+
+ def post_whatsapp_webhook(path, body, signature: signature_for(body), env: { WHATSAPP_APP_SECRET: client_secret })
+ with_modified_env env do
+ post path,
+ params: body,
+ headers: { 'CONTENT_TYPE' => 'application/json', 'X-Hub-Signature-256' => signature }
+ end
+ end
+
+ def post_unsigned_whatsapp_webhook(path, body, env: { WHATSAPP_APP_SECRET: client_secret })
+ with_modified_env env do
+ post path,
+ params: body,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+ end
+
+ before do
+ InstallationConfig.where(name: 'WHATSAPP_APP_SECRET').delete_all
+ GlobalConfig.clear_cache
+ end
describe 'GET /webhooks/verify' do
it 'returns 401 when valid params are not present' do
@@ -23,13 +50,103 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
end
describe 'POST /webhooks/whatsapp/{:phone_number}' do
- it 'call the whatsapp events job with the params' do
+ it 'calls the whatsapp events job with the params for a valid signature' do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
- post '/webhooks/whatsapp/123221321', params: { content: 'hello' }
+ post_whatsapp_webhook('/webhooks/whatsapp/123221321', body)
expect(response).to have_http_status(:success)
end
+ it 'accepts webhook payloads signed with the channel app secret' do
+ channel_secret = 'channel-whatsapp-secret'
+ channel.provider_config = channel.provider_config.merge('app_secret' => channel_secret)
+ channel.save!
+
+ allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+ expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+
+ channel_body = {
+ object: 'whatsapp_business_account',
+ entry: [{
+ changes: [{
+ value: {
+ metadata: {
+ display_phone_number: channel.phone_number.delete_prefix('+'),
+ phone_number_id: channel.provider_config['phone_number_id']
+ }
+ }
+ }]
+ }]
+ }.to_json
+
+ post_whatsapp_webhook(
+ "/webhooks/whatsapp/#{channel.phone_number}",
+ channel_body,
+ signature: signature_for(channel_body, channel_secret),
+ env: {}
+ )
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'skips signature validation for 360dialog channels' do
+ dialog_channel = create(:channel_whatsapp, provider: 'default', sync_templates: false, validate_provider_config: false)
+ allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+ expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+
+ post_unsigned_whatsapp_webhook("/webhooks/whatsapp/#{dialog_channel.phone_number}", body)
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'skips signature validation for manual whatsapp cloud channels without an app secret' do
+ channel.update!(
+ provider_config: channel.provider_config.except('app_secret', 'app_secret_key', 'api_secret', 'client_secret', 'source')
+ )
+ allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+ expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+
+ channel_body = {
+ object: 'whatsapp_business_account',
+ entry: [{
+ changes: [{
+ value: {
+ metadata: {
+ display_phone_number: channel.phone_number.delete_prefix('+'),
+ phone_number_id: channel.provider_config['phone_number_id']
+ }
+ }
+ }]
+ }]
+ }.to_json
+
+ post_unsigned_whatsapp_webhook("/webhooks/whatsapp/#{channel.phone_number}", channel_body)
+
+ expect(response).to have_http_status(:success)
+ end
+
+ it 'returns unauthorized when signature is missing' do
+ allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+
+ with_modified_env WHATSAPP_APP_SECRET: client_secret do
+ post '/webhooks/whatsapp/123221321',
+ params: body,
+ headers: { 'CONTENT_TYPE' => 'application/json' }
+ end
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later)
+ end
+
+ it 'returns unauthorized when signature is invalid' do
+ allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
+
+ post_whatsapp_webhook('/webhooks/whatsapp/123221321', body, signature: 'sha256=invalid-signature')
+
+ expect(response).to have_http_status(:unauthorized)
+ expect(Webhooks::WhatsappEventsJob).not_to have_received(:perform_later)
+ end
+
context 'when phone number is in inactive list' do
before do
allow(GlobalConfig).to receive(:get_value).with('INACTIVE_WHATSAPP_NUMBERS').and_return('+1234567890,+9876543210')
@@ -39,7 +156,7 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
allow(Rails.logger).to receive(:warn)
expect(Rails.logger).to receive(:warn).with('Rejected webhook for inactive WhatsApp number: +1234567890')
- post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' }
+ post_whatsapp_webhook('/webhooks/whatsapp/+1234567890', body)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('Inactive WhatsApp number')
end
@@ -54,7 +171,7 @@ RSpec.describe 'Webhooks::WhatsappController', type: :request do
allow(Webhooks::WhatsappEventsJob).to receive(:perform_later)
expect(Webhooks::WhatsappEventsJob).to receive(:perform_later)
- post '/webhooks/whatsapp/+1234567890', params: { content: 'hello' }
+ post_whatsapp_webhook('/webhooks/whatsapp/+1234567890', body)
expect(response).to have_http_status(:success)
end
end
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 548b84992..8fac81d60 100644
--- a/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
+++ b/spec/enterprise/jobs/captain/conversation/response_builder_job_spec.rb
@@ -10,6 +10,7 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
let(:conversation) { create(:conversation, inbox: inbox, account: account, status: :pending) }
let(:mock_llm_chat_service) { instance_double(Captain::Llm::AssistantChatService) }
let(:mock_agent_runner_service) { instance_double(Captain::Assistant::AgentRunnerService) }
+ let(:mock_action_classifier_service) { instance_double(Captain::Llm::AssistantActionClassifierService) }
before do
create(:message, conversation: conversation, content: 'Hello', message_type: :incoming)
@@ -19,6 +20,8 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain Specs' })
allow(Captain::Assistant::AgentRunnerService).to receive(:new).and_return(mock_agent_runner_service)
allow(mock_agent_runner_service).to receive(:generate_response).and_return({ 'response' => 'Hey, welcome to Captain V2' })
+ allow(Captain::Llm::AssistantActionClassifierService).to receive(:new).and_return(mock_action_classifier_service)
+ allow(mock_action_classifier_service).to receive(:classify).and_return({ 'action' => 'continue' })
end
context 'when captain_v2 is disabled' do
@@ -48,6 +51,107 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
expect(account.usage_limits[:captain][:responses][:consumed]).to eq(1)
end
+ it 'does not run the action classifier when the classifier feature is disabled' do
+ expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.messages.last.content).to eq('Hey, welcome to Captain Specs')
+ end
+
+ context 'when V1 action classifier is enabled' do
+ before do
+ allow(account).to receive(:feature_enabled?).and_return(false)
+ allow(account).to receive(:feature_enabled?).with('captain_integration_v2').and_return(false)
+ allow(account).to receive(:feature_enabled?).with('captain_v1_action_classifier').and_return(true)
+ end
+
+ it 'keeps the conversation pending when the classifier returns continue' do
+ expect(Captain::Llm::AssistantActionClassifierService).to receive(:new).with(
+ assistant: assistant,
+ conversation: conversation
+ ).and_return(mock_action_classifier_service)
+ expect(mock_action_classifier_service).to receive(:classify).with(
+ message_history: [{ content: 'Hello', role: 'user' }],
+ assistant_response: 'Hey, welcome to Captain Specs'
+ ).and_return({
+ 'action' => 'continue',
+ 'action_reason' => 'general_product_question',
+ 'model' => 'gpt-4.1'
+ })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('pending')
+ expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
+ end
+
+ it 'hands off without incrementing response usage when the classifier returns handoff' do
+ allow(mock_action_classifier_service).to receive(:classify).and_return({
+ 'action' => 'handoff',
+ 'action_reason' => 'explicit_human_request',
+ 'model' => 'gpt-4.1'
+ })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
+ end
+
+ it 'skips the classifier when the legacy handoff token is returned' do
+ allow(mock_llm_chat_service).to receive(:generate_response).and_return({ 'response' => 'conversation_handoff' })
+ expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('open')
+ expect(conversation.messages.outgoing.last.content).to eq(I18n.t('conversations.captain.handoff'))
+ end
+
+ it 'falls back to the assistant response when the classifier fails' do
+ error = StandardError.new('classifier unavailable')
+ allow(mock_action_classifier_service).to receive(:classify).and_raise(error)
+ allow(ChatwootExceptionTracker).to receive(:new).and_call_original
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(ChatwootExceptionTracker).to have_received(:new).with(error, account: account)
+ expect(conversation.reload.status).to eq('pending')
+ expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
+ end
+
+ it 'falls back to the assistant response when the classifier returns an invalid action' do
+ allow(mock_action_classifier_service).to receive(:classify).and_return({
+ 'action' => nil,
+ 'error' => 'invalid_classifier_response'
+ })
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.reload.status).to eq('pending')
+ expect(conversation.messages.outgoing.last.content).to eq('Hey, welcome to Captain Specs')
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(1)
+ end
+
+ it 'skips the classifier when the conversation is no longer pending after response generation' do
+ allow(mock_llm_chat_service).to receive(:generate_response) do
+ conversation.open!
+ { 'response' => 'Hey, welcome to Captain Specs' }
+ end
+
+ expect(Captain::Llm::AssistantActionClassifierService).not_to receive(:new)
+
+ described_class.perform_now(conversation, assistant)
+
+ expect(conversation.messages.outgoing.count).to eq(0)
+ expect(account.reload.usage_limits[:captain][:responses][:consumed]).to eq(0)
+ end
+ end
+
it 'does not send a response when the conversation is no longer pending' do
conversation.open!
@@ -292,9 +396,11 @@ RSpec.describe Captain::Conversation::ResponseBuilderJob, type: :job do
it 'handles API errors and triggers handoff' do
allow(mock_llm_chat_service).to receive(:generate_response)
.and_raise(Faraday::BadRequestError, 'Bad request to image service')
+ allow(Rails.logger).to receive(:info).and_call_original
described_class.perform_now(conversation, assistant)
expect(conversation.reload.status).to eq('open')
+ expect(Rails.logger).to have_received(:info).with(include('source=error reason=faraday_bad_request_error'))
end
it 'succeeds when no error occurs' do
diff --git a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
index e7a16db5b..66828ef9f 100644
--- a/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
+++ b/spec/enterprise/jobs/captain/documents/schedule_syncs_job_spec.rb
@@ -5,6 +5,7 @@ RSpec.describe Captain::Documents::ScheduleSyncsJob, type: :job do
let(:assistant) { create(:captain_assistant, account: account) }
before do
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24, hacker: nil }.to_json)
account.enable_features!('captain_document_auto_sync')
clear_enqueued_jobs
end
diff --git a/spec/enterprise/models/account_spec.rb b/spec/enterprise/models/account_spec.rb
index 8183c76ff..c69a83256 100644
--- a/spec/enterprise/models/account_spec.rb
+++ b/spec/enterprise/models/account_spec.rb
@@ -225,46 +225,68 @@ RSpec.describe Account, type: :model do
describe 'captain document sync cadence' do
let(:account) { create(:account) }
- it 'has no cadence on the hacker plan' do
- account.update!(custom_attributes: { plan_name: 'hacker' })
- expect(account.captain_document_sync_interval).to be_nil
- end
-
- it 'syncs weekly on the startups plan' do
- account.update!(custom_attributes: { plan_name: 'startups' })
- expect(account.captain_document_sync_interval).to eq(7.days)
- end
-
- it 'syncs daily on the business plan' do
+ it 'has no cadence when installation config is missing' do
account.update!(custom_attributes: { plan_name: 'business' })
- expect(account.captain_document_sync_interval).to eq(1.day)
- end
-
- it 'syncs every six hours on the enterprise plan' do
- account.update!(custom_attributes: { plan_name: 'enterprise' })
- expect(account.captain_document_sync_interval).to eq(6.hours)
- end
-
- it 'has no cadence when plan is missing' do
- account.update!(custom_attributes: {})
expect(account.captain_document_sync_interval).to be_nil
end
- it 'has no cadence for unknown plans' do
- account.update!(custom_attributes: { plan_name: 'mystery' })
- expect(account.captain_document_sync_interval).to be_nil
+ it 'uses configured plan intervals from installation config' do
+ intervals = {
+ business: 48,
+ enterprise: 24
+ }
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
+ account.update!(custom_attributes: { plan_name: 'business' })
+
+ expect(account.captain_document_sync_interval).to eq(2.days)
end
- it 'normalizes plan name casing' do
+ it 'normalizes configured plan name casing' do
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: 24 }.to_json)
account.update!(custom_attributes: { plan_name: 'Business' })
+
expect(account.captain_document_sync_interval).to eq(1.day)
end
- it 'syncs every six hours on self-hosted enterprise installs without a plan_name' do
+ it 'uses the enterprise cadence for self-hosted enterprise installs without a plan_name' do
allow(ChatwootApp).to receive(:self_hosted_enterprise?).and_return(true)
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { enterprise: 6 }.to_json)
account.update!(custom_attributes: {})
+
expect(account.captain_document_sync_interval).to eq(6.hours)
end
+
+ it 'allows installation config to disable a plan cadence' do
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: { business: nil }.to_json)
+ account.update!(custom_attributes: { plan_name: 'business' })
+
+ expect(account.captain_document_sync_interval).to be_nil
+ end
+
+ it 'has no cadence when installation config is invalid' do
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: 'invalid-json')
+ account.update!(custom_attributes: { plan_name: 'business' })
+
+ expect(account.captain_document_sync_interval).to be_nil
+ end
+
+ it 'treats invalid plan interval values as disabled' do
+ intervals = {
+ business: false,
+ enterprise: { hours: 6 },
+ startups: '168'
+ }
+ create(:installation_config, name: 'CAPTAIN_DOCUMENT_AUTO_SYNC_INTERVALS', value: intervals.to_json)
+
+ account.update!(custom_attributes: { plan_name: 'business' })
+ expect(account.captain_document_sync_interval).to be_nil
+
+ account.update!(custom_attributes: { plan_name: 'enterprise' })
+ expect(account.captain_document_sync_interval).to be_nil
+
+ account.update!(custom_attributes: { plan_name: 'startups' })
+ expect(account.captain_document_sync_interval).to be_nil
+ end
end
describe 'account deletion' do
diff --git a/spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
new file mode 100644
index 000000000..260e3f4f7
--- /dev/null
+++ b/spec/enterprise/services/captain/llm/assistant_action_classifier_service_spec.rb
@@ -0,0 +1,95 @@
+require 'rails_helper'
+
+RSpec.describe Captain::Llm::AssistantActionClassifierService do
+ let(:account) { create(:account) }
+ let(:assistant) do
+ create(
+ :captain_assistant,
+ account: account,
+ config: {
+ 'instructions' => 'Only transfer to a manager after the user explicitly confirms.'
+ }
+ )
+ end
+ let(:conversation) { create(:conversation, account: account) }
+ let(:service) { described_class.new(assistant: assistant, conversation: conversation) }
+ let(:mock_chat) { instance_double(RubyLLM::Chat) }
+ let(:mock_response) do
+ instance_double(
+ RubyLLM::Message,
+ content: { 'action' => 'handoff', 'action_reason' => 'human_offer_accepted' }
+ )
+ end
+
+ before do
+ allow(RubyLLM).to receive(:chat).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_temperature).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_schema).and_return(mock_chat)
+ allow(mock_chat).to receive(:with_instructions).and_return(mock_chat)
+ end
+
+ describe '#classify' do
+ let(:message_history) do
+ [
+ { role: 'user', content: 'I cannot log in' },
+ { role: 'assistant', content: 'Did you check your inbox?' },
+ { role: 'user', content: 'Yes, still no reset email' }
+ ]
+ end
+
+ it 'passes delimited custom instructions and classifier context to the LLM' do
+ expect(mock_chat).to receive(:with_schema).with(Captain::AssistantActionSchema).and_return(mock_chat)
+ expect(mock_chat).to receive(:with_instructions).with(
+ a_string_including('Account custom instructions are provided inside tags.')
+ ).and_return(mock_chat)
+ expect(mock_chat).to receive(:ask) do |prompt|
+ expect(prompt).to include(
+ '',
+ 'Only transfer to a manager after the user explicitly confirms.',
+ '',
+ 'User: I cannot log in',
+ 'Assistant: Did you check your inbox?',
+ 'User: Yes, still no reset email',
+ '',
+ 'Would you like to talk to support?'
+ )
+ expect(prompt).not_to include('"role"', '"content"', '')
+
+ mock_response
+ end
+
+ result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
+
+ expect(result).to include(
+ 'action' => 'handoff',
+ 'action_reason' => 'human_offer_accepted'
+ )
+ end
+
+ it 'uses the configured Captain model' do
+ create(:installation_config, name: 'CAPTAIN_OPEN_AI_MODEL', value: 'gpt-4.1-nano')
+
+ expect(RubyLLM).to receive(:chat).with(model: 'gpt-4.1-nano').and_return(mock_chat)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+
+ result = service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
+
+ expect(result).to include('model' => 'gpt-4.1-nano')
+ end
+
+ context 'when the assistant has no custom instructions' do
+ before do
+ assistant.update!(config: assistant.config.except('instructions'))
+ end
+
+ it 'does not add custom-instruction policy to the system prompt' do
+ expect(mock_chat).to receive(:with_instructions).with(
+ satisfy { |prompt| prompt.exclude?('Account custom instructions are provided') }
+ ).and_return(mock_chat)
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+
+ service.classify(message_history: message_history, assistant_response: 'Would you like to talk to support?')
+ end
+ end
+ end
+end
diff --git a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
index c43eb08bd..9d233943e 100644
--- a/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
+++ b/spec/enterprise/services/captain/llm/assistant_chat_service_spec.rb
@@ -188,4 +188,29 @@ RSpec.describe Captain::Llm::AssistantChatService do
end
end
end
+
+ describe 'account custom instructions in system prompt' do
+ before do
+ assistant.update!(config: assistant.config.merge('instructions' => 'if user enters 1112234 suggest handoff'))
+ end
+
+ it 'adds custom instructions in a separate delimited section' do
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
+
+ expect(mock_chat).to receive(:with_instructions).with(
+ a_string_including(
+ '',
+ 'if user enters 1112234 suggest handoff',
+ ''
+ )
+ ) do |instructions|
+ expect(instructions).not_to include('')
+ expect(instructions.index('')).to be < instructions.index('```json')
+ mock_chat
+ end
+
+ service = described_class.new(assistant: assistant, conversation: conversation)
+ service.generate_response(message_history: [{ role: 'user', content: 'Hello' }])
+ end
+ end
end
diff --git a/spec/enterprise/services/messages/audio_transcription_service_spec.rb b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
index 7ece2540a..212c0bf01 100644
--- a/spec/enterprise/services/messages/audio_transcription_service_spec.rb
+++ b/spec/enterprise/services/messages/audio_transcription_service_spec.rb
@@ -63,6 +63,23 @@ RSpec.describe Messages::AudioTranscriptionService, type: :service do
expect(result).to eq({ success: true, transcriptions: 'Existing transcription' })
end
end
+
+ context 'when the audio exceeds Whisper byte limit' do
+ before do
+ attachment.file.attach(
+ io: File.open(Rails.public_path.join('audio/widget/ding.mp3')),
+ filename: 'large.mp3',
+ content_type: 'audio/mpeg'
+ )
+ allow(service).to receive(:can_transcribe?).and_return(true)
+ allow(attachment.file.blob).to receive(:byte_size).and_return(described_class::WHISPER_BYTE_LIMIT + 1)
+ end
+
+ it 'returns an error without calling Whisper' do
+ expect(service).not_to receive(:transcribe_audio)
+ expect(service.perform).to eq({ error: 'Audio too large for Whisper' })
+ end
+ end
end
describe '#fetch_audio_file' do
diff --git a/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb
new file mode 100644
index 000000000..b67d5b7cd
--- /dev/null
+++ b/spec/enterprise/services/voice/provider/twilio/recording_attachment_service_spec.rb
@@ -0,0 +1,124 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Voice::Provider::Twilio::RecordingAttachmentService do
+ let(:account) { create(:account) }
+ let(:channel) do
+ create(:channel_twilio_sms, :with_voice,
+ account: account,
+ phone_number: '+15551238888',
+ account_sid: 'AC_account_sid',
+ auth_token: 'auth_token_value')
+ end
+ let(:inbox) { channel.inbox }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:call) do
+ create(
+ :call,
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ contact: conversation.contact
+ )
+ end
+ let!(:message) do
+ msg = conversation.messages.create!(
+ account_id: account.id,
+ inbox_id: inbox.id,
+ message_type: :incoming,
+ sender: conversation.contact,
+ content: 'Voice Call',
+ content_type: 'voice_call'
+ )
+ call.update!(message_id: msg.id)
+ msg
+ end
+
+ let(:recording_sid) { 'RE9999' }
+ let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE9999' }
+ let(:recording_duration) { '47' }
+
+ let(:downloaded_tempfile) do
+ file = Tempfile.new(['call-recording', '.wav'])
+ file.binmode
+ file.write('FAKE_AUDIO_BYTES')
+ file.rewind
+ file
+ end
+
+ let(:safe_fetch_result) do
+ SafeFetch::Result.new(
+ tempfile: downloaded_tempfile,
+ filename: 'recording.wav',
+ content_type: 'audio/wav'
+ )
+ end
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new)
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
+
+ allow(SafeFetch).to receive(:fetch)
+ .with(recording_url, http_basic_authentication: %w[AC_account_sid auth_token_value],
+ allowed_content_type_prefixes: %w[audio/])
+ .and_yield(safe_fetch_result)
+ end
+
+ def perform_service(overrides = {})
+ described_class.new(
+ call: call,
+ recording_sid: overrides.fetch(:recording_sid, recording_sid),
+ recording_url: overrides.fetch(:recording_url, recording_url),
+ recording_duration: overrides.fetch(:recording_duration, recording_duration)
+ ).perform
+ end
+
+ describe '#perform' do
+ it 'attaches the recording to the call and persists recording_sid + duration_seconds' do
+ previous_updated_at = message.updated_at
+ travel 1.second
+
+ perform_service
+
+ call.reload
+ message.reload
+
+ aggregate_failures do
+ expect(call.recording).to be_attached
+ expect(call.recording_sid).to eq(recording_sid)
+ expect(call.duration_seconds).to eq(47)
+ expect(message.updated_at).to be > previous_updated_at
+ end
+ end
+
+ it 'preserves a duration_seconds value that was already set on the call' do
+ call.update!(duration_seconds: 99)
+
+ perform_service
+
+ expect(call.reload.duration_seconds).to eq(99)
+ end
+
+ it 'is idempotent when the same recording_sid is already attached' do
+ perform_service
+
+ expect(SafeFetch).to have_received(:fetch).once
+
+ perform_service
+
+ expect(SafeFetch).to have_received(:fetch).once
+ expect(call.reload.recording.blob.checksum).to be_present
+ end
+
+ it 'is a no-op when recording_sid is blank' do
+ expect { perform_service(recording_sid: '') }.not_to change { call.reload.recording.attached? }.from(false)
+ expect(SafeFetch).not_to have_received(:fetch)
+ end
+
+ it 'is a no-op when recording_url is blank' do
+ expect { perform_service(recording_url: '') }.not_to change { call.reload.recording.attached? }.from(false)
+ expect(SafeFetch).not_to have_received(:fetch)
+ end
+ end
+end
diff --git a/spec/enterprise/services/voice/recording_status_service_spec.rb b/spec/enterprise/services/voice/recording_status_service_spec.rb
new file mode 100644
index 000000000..3d9227709
--- /dev/null
+++ b/spec/enterprise/services/voice/recording_status_service_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Voice::RecordingStatusService do
+ let(:account) { create(:account) }
+ let(:channel) { create(:channel_twilio_sms, :with_voice, account: account, phone_number: '+15551237777') }
+ let(:inbox) { channel.inbox }
+ let(:conversation) { create(:conversation, account: account, inbox: inbox) }
+ let(:conference_sid) { 'CFabc123def456' }
+ let!(:call) do
+ create(
+ :call,
+ account: account,
+ inbox: inbox,
+ conversation: conversation,
+ contact: conversation.contact,
+ meta: { 'twilio_conference_sid' => conference_sid }
+ )
+ end
+
+ let(:recording_sid) { 'RE1234567890abcdef' }
+ let(:recording_url) { 'https://api.twilio.com/2010-04-01/Accounts/AC1/Recordings/RE1' }
+ let(:recording_duration) { '12' }
+
+ let(:complete_payload) do
+ {
+ 'RecordingStatus' => 'completed',
+ 'ConferenceSid' => conference_sid,
+ 'RecordingSid' => recording_sid,
+ 'RecordingUrl' => recording_url,
+ 'RecordingDuration' => recording_duration
+ }
+ end
+
+ before do
+ allow(Twilio::VoiceWebhookSetupService).to receive(:new)
+ .and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: "AP#{SecureRandom.hex(8)}"))
+ end
+
+ describe '#perform' do
+ it 'enqueues the recording attachment job for the matching call' do
+ expect do
+ described_class.new(account: account, payload: complete_payload).perform
+ end.to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
+ .with(call.id, recording_sid, recording_url, recording_duration)
+ end
+
+ it 'is a no-op when RecordingStatus is not completed' do
+ payload = complete_payload.merge('RecordingStatus' => 'in-progress')
+
+ expect do
+ described_class.new(account: account, payload: payload).perform
+ end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
+ end
+
+ it 'is a no-op when ConferenceSid is missing' do
+ payload = complete_payload.except('ConferenceSid')
+
+ expect do
+ described_class.new(account: account, payload: payload).perform
+ end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
+ end
+
+ it 'is a no-op when RecordingSid is missing' do
+ payload = complete_payload.except('RecordingSid')
+
+ expect do
+ described_class.new(account: account, payload: payload).perform
+ end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
+ end
+
+ it 'is a no-op when RecordingUrl is missing' do
+ payload = complete_payload.except('RecordingUrl')
+
+ expect do
+ described_class.new(account: account, payload: payload).perform
+ end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
+ end
+
+ it 'is a no-op when no Call matches the ConferenceSid' do
+ payload = complete_payload.merge('ConferenceSid' => 'CFunknown')
+
+ expect do
+ described_class.new(account: account, payload: payload).perform
+ end.not_to have_enqueued_job(Voice::Provider::Twilio::RecordingAttachmentJob)
+ end
+ end
+end
diff --git a/spec/services/imap/fetch_email_service_spec.rb b/spec/services/imap/fetch_email_service_spec.rb
index 46336bf0f..2910f41fb 100644
--- a/spec/services/imap/fetch_email_service_spec.rb
+++ b/spec/services/imap/fetch_email_service_spec.rb
@@ -7,6 +7,7 @@ RSpec.describe Imap::FetchEmailService do
let(:imap_email_channel) { create(:channel_email, :imap_email, account: account) }
let(:imap) { instance_double(Net::IMAP) }
let(:eml_content_with_message_id) { Rails.root.join('spec/fixtures/files/only_text.eml').read }
+ let(:eml_content_without_message_id) { eml_content_with_message_id.sub(/^Message-ID:.*\n/, '') }
describe '#perform' do
before do
@@ -63,6 +64,34 @@ RSpec.describe Imap::FetchEmailService do
expect(imap).not_to have_received(:fetch).with(1, 'RFC822')
end
end
+
+ it 'does not count emails without message ids toward the sync limit' do
+ travel_to '26.10.2020 10:00'.to_datetime do
+ email_object = create_inbound_email_from_fixture('only_text.eml')
+ max_messages_per_sync = Imap::BaseFetchEmailService::MAX_MESSAGES_PER_SYNC
+ empty_message_id_seq_nums = (1..max_messages_per_sync).to_a
+ valid_message_seq_num = max_messages_per_sync + 1
+ empty_message_id_headers = empty_message_id_seq_nums.map do |seq_num|
+ Net::IMAP::FetchData.new(seq_num, 'BODY[HEADER]' => eml_content_without_message_id)
+ end
+ valid_email_header = Net::IMAP::FetchData.new(valid_message_seq_num, 'BODY[HEADER]' => eml_content_with_message_id)
+ imap_fetch_mail = Net::IMAP::FetchData.new(valid_message_seq_num, 'RFC822' => eml_content_with_message_id)
+
+ allow(imap).to receive(:search).with(%w[SINCE 25-Oct-2020]).and_return(empty_message_id_seq_nums + [valid_message_seq_num])
+ allow(imap).to receive(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]').and_return(empty_message_id_headers)
+ allow(imap).to receive(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]').and_return([valid_email_header])
+ allow(imap).to receive(:fetch).with(valid_message_seq_num, 'RFC822').and_return([imap_fetch_mail])
+ allow(imap).to receive(:logout)
+
+ result = described_class.new(channel: imap_email_channel).perform
+
+ expect(result.length).to eq 1
+ expect(result[0].message_id).to eq email_object.message_id
+ expect(imap).to have_received(:fetch).with(empty_message_id_seq_nums, 'BODY.PEEK[HEADER]')
+ expect(imap).to have_received(:fetch).with([valid_message_seq_num], 'BODY.PEEK[HEADER]')
+ expect(imap).to have_received(:fetch).with(valid_message_seq_num, 'RFC822')
+ end
+ end
end
end
end
diff --git a/theme/icons.js b/theme/icons.js
index 8a29e2bda..266c7ddfa 100644
--- a/theme/icons.js
+++ b/theme/icons.js
@@ -423,5 +423,10 @@ export const icons = {
width: 16,
height: 16,
},
+ 'quick-reply': {
+ body: ``,
+ width: 15,
+ height: 15,
+ },
/** Ends */
};
diff --git a/vite.config.ts b/vite.config.ts
index e06b0f1fb..17d47fc76 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -22,6 +22,7 @@ import { defineConfig } from 'vite';
import ruby from 'vite-plugin-ruby';
import path from 'path';
import vue from '@vitejs/plugin-vue';
+import yaml from '@rollup/plugin-yaml';
const isLibraryMode = process.env.BUILD_MODE === 'library';
const isTestMode = process.env.TEST === 'true';
@@ -34,12 +35,12 @@ const vueOptions = {
},
};
-let plugins = [ruby(), vue(vueOptions)];
+let plugins = [ruby(), vue(vueOptions), yaml()];
if (isLibraryMode) {
plugins = [];
} else if (isTestMode) {
- plugins = [vue(vueOptions)];
+ plugins = [vue(vueOptions), yaml()];
}
export default defineConfig({