+
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/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js
index 5cba13569..c2e756111 100644
--- a/app/javascript/dashboard/composables/useCallSession.js
+++ b/app/javascript/dashboard/composables/useCallSession.js
@@ -1,8 +1,10 @@
import { computed, ref, watch, onUnmounted, onMounted } from 'vue';
import { useStore } from 'vuex';
+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 {
useWhatsappCallSession,
sendWhatsappTerminateBeacon,
@@ -17,6 +19,7 @@ export function useCallSession() {
const store = useStore();
const callsStore = useCallsStore();
const whatsappSession = useWhatsappCallSession();
+ const { t } = useI18n();
const isJoining = ref(false);
const callDuration = ref(0);
const durationTimer = new Timer(elapsed => {
@@ -153,6 +156,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);
// Drop any half-built WebRTC state so the next click starts fresh.
diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js
index 0c798164f..3418b161c 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 {
@@ -31,6 +54,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,
};
}
@@ -38,10 +62,26 @@ function extractCallData(message) {
export function handleVoiceCallCreated(message, currentUserId) {
if (!isVoiceCallMessage(message)) return;
- const { callSid, callId, provider, callDirection, conversationId, senderId } =
- extractCallData(message);
+ const {
+ callSid,
+ callId,
+ provider,
+ callDirection,
+ conversationId,
+ assigneeId,
+ senderId,
+ } = extractCallData(message);
- if (shouldSkipCall(callDirection, senderId, currentUserId)) return;
+ if (
+ !shouldShowCall({
+ callDirection,
+ senderId,
+ assigneeId,
+ currentUserId,
+ })
+ ) {
+ return;
+ }
const callsStore = useCallsStore();
callsStore.addCall({
@@ -57,8 +97,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();
@@ -70,11 +116,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,
@@ -83,3 +137,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 358e6ce96..aa512f569 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/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 94f44be3c..48f8015cf 100644
--- a/app/javascript/dashboard/stores/calls.js
+++ b/app/javascript/dashboard/stores/calls.js
@@ -78,5 +78,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/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/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/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 ca77e45a7..dc4bf2ebf 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 ebb38efc4..d785e8b62 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -622,6 +622,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/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/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/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/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/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/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 */
};