Merge branch 'develop' into feat/github-integration
This commit is contained in:
@@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def sync_templates
|
||||
unless @inbox.channel.is_a?(Channel::Whatsapp)
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
||||
end
|
||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel?
|
||||
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
trigger_template_sync
|
||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||
rescue StandardError => e
|
||||
render status: :internal_server_error, json: { error: e.message }
|
||||
@@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
def whatsapp_channel?
|
||||
@inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?)
|
||||
end
|
||||
|
||||
def trigger_template_sync
|
||||
if @inbox.whatsapp?
|
||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
elsif @inbox.twilio? && @inbox.channel.whatsapp?
|
||||
Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||
|
||||
-177
@@ -1,177 +0,0 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const templateName = computed(() => {
|
||||
return props.template?.name || '';
|
||||
});
|
||||
|
||||
const templateString = computed(() => {
|
||||
return props.template?.components?.find(
|
||||
component => component.type === 'BODY'
|
||||
).text;
|
||||
});
|
||||
|
||||
const processVariable = str => {
|
||||
return str.replace(/{{|}}/g, '');
|
||||
};
|
||||
|
||||
const processedString = computed(() => {
|
||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
||||
});
|
||||
});
|
||||
|
||||
const processedStringWithVariableHighlight = computed(() => {
|
||||
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
|
||||
|
||||
return variables.reduce((result, variable) => {
|
||||
const variableKey = processVariable(variable);
|
||||
const value = processedParams.value[variableKey] || variable;
|
||||
return result.replace(
|
||||
variable,
|
||||
`<span class="break-all text-n-slate-12">${value}</span>`
|
||||
);
|
||||
}, templateString.value);
|
||||
});
|
||||
|
||||
const rules = computed(() => {
|
||||
const paramRules = {};
|
||||
Object.keys(processedParams.value).forEach(key => {
|
||||
paramRules[key] = { required: requiredIf(true) };
|
||||
});
|
||||
return {
|
||||
processedParams: paramRules,
|
||||
};
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(rules, { processedParams });
|
||||
|
||||
const getFieldErrorType = key => {
|
||||
if (!v$.value.processedParams[key]?.$error) return 'info';
|
||||
return 'error';
|
||||
};
|
||||
|
||||
const generateVariables = () => {
|
||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
||||
if (!matchedVariables) return;
|
||||
|
||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
||||
acc[variable] = '';
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const sendMessage = async () => {
|
||||
const isValid = await v$.value.$validate();
|
||||
if (!isValid) return;
|
||||
|
||||
const payload = {
|
||||
message: processedString.value,
|
||||
templateParams: {
|
||||
name: props.template.name,
|
||||
category: props.template.category,
|
||||
language: props.template.language,
|
||||
namespace: props.template.namespace,
|
||||
processed_params: processedParams.value,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
generateVariables();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<span class="text-sm text-n-slate-12">
|
||||
{{
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
|
||||
{ templateName: templateName }
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<p
|
||||
class="mb-0 text-sm text-n-slate-11"
|
||||
v-html="processedStringWithVariableHighlight"
|
||||
/>
|
||||
|
||||
<span
|
||||
v-if="Object.keys(processedParams).length"
|
||||
class="text-sm font-medium text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-for="(variable, key) in processedParams"
|
||||
:key="key"
|
||||
class="flex items-center w-full gap-2"
|
||||
>
|
||||
<span
|
||||
class="block h-8 text-sm min-w-6 text-start truncate text-n-slate-10 leading-8"
|
||||
:title="key"
|
||||
>
|
||||
{{ key }}
|
||||
</span>
|
||||
<Input
|
||||
v-model="processedParams[key]"
|
||||
custom-input-class="!h-8 w-full !bg-transparent"
|
||||
class="w-full"
|
||||
:message-type="getFieldErrorType(key)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end justify-between w-full gap-3 h-14">
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
||||
)
|
||||
"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="emit('back')"
|
||||
/>
|
||||
<Button
|
||||
:label="
|
||||
t(
|
||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
||||
)
|
||||
"
|
||||
class="w-full font-medium"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -41,9 +41,7 @@ const originalEmailText = computed(() => {
|
||||
});
|
||||
|
||||
const originalEmailHtml = computed(
|
||||
() =>
|
||||
contentAttributes?.value?.email?.htmlContent?.full ??
|
||||
originalEmailText.value
|
||||
() => contentAttributes?.value?.email?.htmlContent?.full || ''
|
||||
);
|
||||
|
||||
const messageContent = computed(() => {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
* 1. Displays the template text with variable placeholders.
|
||||
* 2. Generates input fields for each variable in the template.
|
||||
* 3. Validates that all variables are filled before sending.
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
|
||||
@@ -6,15 +6,11 @@ import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
ALLOWED_FILE_TYPES_FOR_LINE,
|
||||
ALLOWED_FILE_TYPES_FOR_INSTAGRAM,
|
||||
} from 'shared/constants/messages';
|
||||
import { getAllowedFileTypesByChannel } from '@chatwoot/utils';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { mapGetters } from 'vuex';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -196,17 +192,16 @@ export default {
|
||||
return this.conversationType === 'instagram_direct_message';
|
||||
},
|
||||
allowedFileTypes() {
|
||||
if (this.isATwilioWhatsAppChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
|
||||
}
|
||||
if (this.isALineChannel) {
|
||||
return ALLOWED_FILE_TYPES_FOR_LINE;
|
||||
}
|
||||
let channelType = this.channelType || this.inbox?.channel_type;
|
||||
|
||||
if (this.isAnInstagramChannel || this.isInstagramDM) {
|
||||
return ALLOWED_FILE_TYPES_FOR_INSTAGRAM;
|
||||
channelType = INBOX_TYPES.INSTAGRAM;
|
||||
}
|
||||
|
||||
return ALLOWED_FILE_TYPES;
|
||||
return getAllowedFileTypesByChannel({
|
||||
channelType,
|
||||
medium: this.inbox?.medium,
|
||||
});
|
||||
},
|
||||
enableDragAndDrop() {
|
||||
return !this.newConversationModalActive;
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import TemplatesPicker from './TemplatesPicker.vue';
|
||||
import TemplateParser from './TemplateParser.vue';
|
||||
import WhatsAppTemplateReply from './WhatsAppTemplateReply.vue';
|
||||
export default {
|
||||
components: {
|
||||
TemplatesPicker,
|
||||
TemplateParser,
|
||||
WhatsAppTemplateReply,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
@@ -68,7 +68,7 @@ export default {
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
<WhatsAppTemplateReply
|
||||
v-else
|
||||
:template="selectedWaTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
|
||||
-9
@@ -1,13 +1,4 @@
|
||||
<script setup>
|
||||
/**
|
||||
* This component handles parsing and sending WhatsApp message templates.
|
||||
* It works as follows:
|
||||
* 1. Displays the template text with variable placeholders.
|
||||
* 2. Generates input fields for each variable in the template.
|
||||
* 3. Validates that all variables are filled before sending.
|
||||
* 4. Replaces placeholders with user-provided values.
|
||||
* 5. Emits events to send the processed message or reset the template.
|
||||
*/
|
||||
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL } from 'shared/constants/messages';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables', () => ({
|
||||
@@ -13,6 +13,7 @@ vi.mock('dashboard/composables', () => ({
|
||||
vi.mock('vue-i18n');
|
||||
vi.mock('activestorage');
|
||||
vi.mock('shared/helpers/FileHelper');
|
||||
vi.mock('@chatwoot/utils');
|
||||
|
||||
describe('useFileUpload', () => {
|
||||
const mockAttachFile = vi.fn();
|
||||
@@ -22,6 +23,11 @@ describe('useFileUpload', () => {
|
||||
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
|
||||
};
|
||||
|
||||
const inbox = {
|
||||
channel_type: 'Channel::WhatsApp',
|
||||
medium: 'whatsapp',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -37,11 +43,12 @@ describe('useFileUpload', () => {
|
||||
|
||||
useI18n.mockReturnValue({ t: mockTranslate });
|
||||
checkFileSizeLimit.mockReturnValue(true);
|
||||
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
|
||||
});
|
||||
|
||||
it('should handle direct file upload when enabled', () => {
|
||||
it('handles direct file upload when direct uploads enabled', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -52,6 +59,16 @@ describe('useFileUpload', () => {
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
// size rules called with inbox + mime
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
|
||||
channelType: inbox.channel_type,
|
||||
medium: inbox.medium,
|
||||
mime: 'image/jpeg',
|
||||
});
|
||||
|
||||
// size check called with max from helper
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
|
||||
|
||||
expect(DirectUpload).toHaveBeenCalledWith(
|
||||
mockFile.file,
|
||||
'/api/v1/accounts/123/conversations/456/direct_uploads',
|
||||
@@ -63,7 +80,7 @@ describe('useFileUpload', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle indirect file upload when direct upload is disabled', () => {
|
||||
it('handles indirect file upload when direct upload disabled', () => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
getCurrentAccountId: { value: '123' },
|
||||
@@ -75,22 +92,24 @@ describe('useFileUpload', () => {
|
||||
});
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
expect(DirectUpload).not.toHaveBeenCalled();
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
|
||||
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
|
||||
});
|
||||
|
||||
it('should show alert when file size exceeds limit', () => {
|
||||
it('shows alert when file size exceeds limit', () => {
|
||||
checkFileSizeLimit.mockReturnValue(false);
|
||||
mockTranslate.mockReturnValue('File size exceeds limit');
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -100,28 +119,37 @@ describe('useFileUpload', () => {
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should use different max file size for Twilio SMS channel', () => {
|
||||
it('uses per-mime limits from helper', () => {
|
||||
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
|
||||
mime.startsWith('image/') ? 10 : 50
|
||||
);
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: true,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
DirectUpload.mockImplementation(() => ({
|
||||
create: cb => cb(null, { signed_id: 'blob' }),
|
||||
}));
|
||||
|
||||
onFileUpload(mockFile);
|
||||
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(
|
||||
mockFile,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
);
|
||||
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
|
||||
channelType: inbox.channel_type,
|
||||
medium: inbox.medium,
|
||||
mime: 'image/jpeg',
|
||||
});
|
||||
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
|
||||
});
|
||||
|
||||
it('should handle direct upload errors', () => {
|
||||
it('handles direct upload errors', () => {
|
||||
const mockError = 'Upload failed';
|
||||
DirectUpload.mockImplementation(() => ({
|
||||
create: callback => callback(mockError, null),
|
||||
}));
|
||||
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
@@ -131,15 +159,16 @@ describe('useFileUpload', () => {
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should do nothing when file is null', () => {
|
||||
it('does nothing when file is null', () => {
|
||||
const { onFileUpload } = useFileUpload({
|
||||
isATwilioSMSChannel: false,
|
||||
inbox,
|
||||
attachFile: mockAttachFile,
|
||||
});
|
||||
|
||||
onFileUpload(null);
|
||||
|
||||
expect(checkFileSizeLimit).not.toHaveBeenCalled();
|
||||
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
|
||||
expect(mockAttachFile).not.toHaveBeenCalled();
|
||||
expect(useAlert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
|
||||
} from 'shared/constants/messages';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
|
||||
/**
|
||||
* Composable for handling file uploads in conversations
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.isATwilioSMSChannel - Whether the current channel is Twilio SMS
|
||||
* @param {Function} options.attachFile - Callback function to handle file attachment
|
||||
* @returns {Object} File upload methods and utilities
|
||||
* @param {Object} options
|
||||
* @param {Object} options.inbox - Current inbox object (has channel_type, medium, etc.)
|
||||
* @param {Function} options.attachFile - Callback to handle file attachment
|
||||
*/
|
||||
export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
export const useFileUpload = ({ inbox, attachFile }) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
const accountId = useMapGetter('getCurrentAccountId');
|
||||
@@ -24,57 +19,66 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const globalConfig = useMapGetter('globalConfig/get');
|
||||
|
||||
const maxFileSize = computed(() =>
|
||||
isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE
|
||||
);
|
||||
// helper: compute max upload size for a given file's mime
|
||||
const maxSizeFor = mime =>
|
||||
getMaxUploadSizeByChannel({
|
||||
channelType: inbox?.channel_type,
|
||||
medium: inbox?.medium, // e.g. 'sms' | 'whatsapp' | etc.
|
||||
mime, // e.g. 'image/png'
|
||||
});
|
||||
|
||||
const alertOverLimit = maxSizeMB =>
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDirectFileUpload = file => {
|
||||
if (!file) return;
|
||||
|
||||
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = maxSizeFor(mime);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
useAlert(error);
|
||||
} else {
|
||||
attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||
})
|
||||
);
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
}
|
||||
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${accountId.value}/conversations/${currentChat.value.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
currentUser.value.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
useAlert(error);
|
||||
} else {
|
||||
attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleIndirectFileUpload = file => {
|
||||
if (!file) return;
|
||||
|
||||
if (checkFileSizeLimit(file, maxFileSize.value)) {
|
||||
attachFile({ file });
|
||||
} else {
|
||||
useAlert(
|
||||
t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxFileSize.value,
|
||||
})
|
||||
);
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = maxSizeFor(mime);
|
||||
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
}
|
||||
|
||||
attachFile({ file });
|
||||
};
|
||||
|
||||
const onFileUpload = file => {
|
||||
@@ -85,7 +89,5 @@ export const useFileUpload = ({ isATwilioSMSChannel, attachFile }) => {
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onFileUpload,
|
||||
};
|
||||
return { onFileUpload };
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AnalyticsBrowser } from '@june-so/analytics-next';
|
||||
import posthog from 'posthog-js';
|
||||
|
||||
/**
|
||||
* AnalyticsHelper class to initialize and track user analytics
|
||||
@@ -26,10 +26,12 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
let [analytics] = await AnalyticsBrowser.load({
|
||||
writeKey: this.analyticsToken,
|
||||
posthog.init(this.analyticsToken, {
|
||||
api_host: 'https://app.posthog.com',
|
||||
capture_pageview: false,
|
||||
persistence: 'localStorage+cookie',
|
||||
});
|
||||
this.analytics = analytics;
|
||||
this.analytics = posthog;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,8 +45,7 @@ export class AnalyticsHelper {
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.email, {
|
||||
userId: this.user.id,
|
||||
this.analytics.identify(this.user.id.toString(), {
|
||||
email: this.user.email,
|
||||
name: this.user.name,
|
||||
avatar: this.user.avatar_url,
|
||||
@@ -55,7 +56,7 @@ export class AnalyticsHelper {
|
||||
account => account.id === accountId
|
||||
);
|
||||
if (currentAccount) {
|
||||
this.analytics.group(currentAccount.id, this.user.id, {
|
||||
this.analytics.group('company', currentAccount.id.toString(), {
|
||||
name: currentAccount.name,
|
||||
});
|
||||
}
|
||||
@@ -71,12 +72,7 @@ export class AnalyticsHelper {
|
||||
if (!this.analytics) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.track({
|
||||
userId: this.user.id,
|
||||
event: eventName,
|
||||
properties,
|
||||
});
|
||||
this.analytics.capture(eventName, properties);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +85,9 @@ export class AnalyticsHelper {
|
||||
return;
|
||||
}
|
||||
|
||||
this.analytics.page(params);
|
||||
this.analytics.capture('$pageview', params);
|
||||
}
|
||||
}
|
||||
|
||||
// This object is shared across, the init is called in app/javascript/packs/application.js
|
||||
// This object is shared across, the init is called in app/javascript/entrypoints/dashboard.js
|
||||
export default new AnalyticsHelper(window.analyticsConfig);
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
vi.mock('@june-so/analytics-next', () => ({
|
||||
AnalyticsBrowser: {
|
||||
load: () => [
|
||||
{
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
page: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
],
|
||||
vi.mock('posthog-js', () => ({
|
||||
default: {
|
||||
init: vi.fn(),
|
||||
identify: vi.fn(),
|
||||
capture: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -26,12 +22,12 @@ describe('AnalyticsHelper', () => {
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should initialize the analytics browser with the correct token', async () => {
|
||||
it('should initialize posthog with the correct token', async () => {
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).not.toBe(null);
|
||||
});
|
||||
|
||||
it('should not initialize the analytics browser if token is not provided', async () => {
|
||||
it('should not initialize posthog if token is not provided', async () => {
|
||||
analyticsHelper = new AnalyticsHelper();
|
||||
await analyticsHelper.init();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -43,36 +39,36 @@ describe('AnalyticsHelper', () => {
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser with correct arguments', () => {
|
||||
it('should call identify on posthog with correct arguments', () => {
|
||||
analyticsHelper.identify({
|
||||
id: '123',
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'avatar_url',
|
||||
accounts: [{ id: '1', name: 'Account 1' }],
|
||||
account_id: '1',
|
||||
accounts: [{ id: 1, name: 'Account 1' }],
|
||||
account_id: 1,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith(
|
||||
'test@example.com',
|
||||
{
|
||||
userId: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
}
|
||||
expect(analyticsHelper.analytics.identify).toHaveBeenCalledWith('123', {
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar: 'avatar_url',
|
||||
});
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalledWith(
|
||||
'company',
|
||||
'1',
|
||||
{ name: 'Account 1' }
|
||||
);
|
||||
expect(analyticsHelper.analytics.group).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser without group', () => {
|
||||
it('should call identify on posthog without group', () => {
|
||||
analyticsHelper.identify({
|
||||
id: '123',
|
||||
id: 123,
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
avatar_url: 'avatar_url',
|
||||
accounts: [{ id: '1', name: 'Account 1' }],
|
||||
account_id: '5',
|
||||
accounts: [{ id: 1, name: 'Account 1' }],
|
||||
account_id: 5,
|
||||
});
|
||||
|
||||
expect(analyticsHelper.analytics.group).not.toHaveBeenCalled();
|
||||
@@ -87,29 +83,27 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: '123' };
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
analyticsHelper.user = { id: 123 };
|
||||
});
|
||||
|
||||
it('should call track on analytics browser with correct arguments', () => {
|
||||
it('should call capture on posthog with correct arguments', () => {
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: { prop1: 'value1', prop2: 'value2' },
|
||||
});
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{ prop1: 'value1', prop2: 'value2' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should call track on analytics browser with default properties', () => {
|
||||
it('should call capture on posthog with default properties', () => {
|
||||
analyticsHelper.track('Test Event');
|
||||
expect(analyticsHelper.analytics.track).toHaveBeenCalledWith({
|
||||
userId: '123',
|
||||
event: 'Test Event',
|
||||
properties: {},
|
||||
});
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'Test Event',
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call track on analytics browser if analytics is not initialized', () => {
|
||||
it('should not call capture on posthog if analytics is not initialized', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.track('Test Event', { prop1: 'value1', prop2: 'value2' });
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
@@ -118,19 +112,22 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { page: vi.fn() };
|
||||
analyticsHelper.analytics = { capture: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the analytics.page method with the correct arguments', () => {
|
||||
it('should call the capture method for pageview with the correct arguments', () => {
|
||||
const params = {
|
||||
name: 'Test page',
|
||||
url: '/test',
|
||||
};
|
||||
analyticsHelper.page(params);
|
||||
expect(analyticsHelper.analytics.page).toHaveBeenCalledWith(params);
|
||||
expect(analyticsHelper.analytics.capture).toHaveBeenCalledWith(
|
||||
'$pageview',
|
||||
params
|
||||
);
|
||||
});
|
||||
|
||||
it('should not call analytics.page if analytics is null', () => {
|
||||
it('should not call analytics.capture if analytics is null', () => {
|
||||
analyticsHelper.analytics = null;
|
||||
analyticsHelper.page();
|
||||
expect(analyticsHelper.analytics).toBe(null);
|
||||
|
||||
@@ -330,13 +330,14 @@
|
||||
"LABEL": "API Key Secret",
|
||||
"PLACEHOLDER": "Enter your Twilio API Key Secret",
|
||||
"REQUIRED": "API Key Secret is required"
|
||||
},
|
||||
"TWIML_APP_SID": {
|
||||
"LABEL": "TwiML App SID",
|
||||
"PLACEHOLDER": "Enter your Twilio TwiML App SID (starts with AP)",
|
||||
"REQUIRED": "TwiML App SID is required"
|
||||
}
|
||||
},
|
||||
"CONFIGURATION": {
|
||||
"TWILIO_VOICE_URL_TITLE": "Twilio Voice URL",
|
||||
"TWILIO_VOICE_URL_SUBTITLE": "Configure this URL as the Voice URL on your Twilio phone number and TwiML App.",
|
||||
"TWILIO_STATUS_URL_TITLE": "Twilio Status Callback URL",
|
||||
"TWILIO_STATUS_URL_SUBTITLE": "Configure this URL as the Status Callback URL on your Twilio phone number."
|
||||
},
|
||||
"SUBMIT_BUTTON": "Create Voice Channel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "We were not able to create the voice channel"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
|
||||
} from 'shared/constants/messages';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
|
||||
export default {
|
||||
@@ -13,7 +10,22 @@ export default {
|
||||
accountId: 'getCurrentAccountId',
|
||||
}),
|
||||
},
|
||||
|
||||
methods: {
|
||||
maxSizeFor(mime) {
|
||||
return getMaxUploadSizeByChannel({
|
||||
channelType: this.inbox?.channel_type,
|
||||
medium: this.inbox?.medium, // e.g. 'sms' | 'whatsapp'
|
||||
mime, // e.g. 'image/png'
|
||||
});
|
||||
},
|
||||
alertOverLimit(maxSizeMB) {
|
||||
useAlert(
|
||||
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE: maxSizeMB,
|
||||
})
|
||||
);
|
||||
},
|
||||
onFileUpload(file) {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
this.onDirectFileUpload(file);
|
||||
@@ -21,59 +33,52 @@ export default {
|
||||
this.onIndirectFileUpload(file);
|
||||
}
|
||||
},
|
||||
|
||||
onDirectFileUpload(file) {
|
||||
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
if (!file) return;
|
||||
|
||||
if (!file) {
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = this.maxSizeFor(mime);
|
||||
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
this.alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
}
|
||||
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
this.currentUser.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
useAlert(error);
|
||||
} else {
|
||||
this.attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
useAlert(
|
||||
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
this.currentUser.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
useAlert(error);
|
||||
} else {
|
||||
this.attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
onIndirectFileUpload(file) {
|
||||
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
if (!file) {
|
||||
if (!file) return;
|
||||
|
||||
const mime = file.file?.type || file.type;
|
||||
const maxSizeMB = this.maxSizeFor(mime);
|
||||
|
||||
if (!checkFileSizeLimit(file, maxSizeMB)) {
|
||||
this.alertOverLimit(maxSizeMB);
|
||||
return;
|
||||
}
|
||||
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
|
||||
this.attachFile({ file });
|
||||
} else {
|
||||
useAlert(
|
||||
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
this.attachFile({ file });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -116,16 +116,22 @@ export default {
|
||||
key: 'collaborators',
|
||||
name: this.$t('INBOX_MGMT.TABS.COLLABORATORS'),
|
||||
},
|
||||
{
|
||||
key: 'businesshours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
|
||||
if (!this.isAVoiceChannel) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
{
|
||||
key: 'businesshours',
|
||||
name: this.$t('INBOX_MGMT.TABS.BUSINESS_HOURS'),
|
||||
},
|
||||
{
|
||||
key: 'csat',
|
||||
name: this.$t('INBOX_MGMT.TABS.CSAT'),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
if (this.isAWebWidgetInbox) {
|
||||
visibleToAllChannelTabs = [
|
||||
...visibleToAllChannelTabs,
|
||||
@@ -144,6 +150,7 @@ export default {
|
||||
this.isATwilioChannel ||
|
||||
this.isALineChannel ||
|
||||
this.isAPIInbox ||
|
||||
this.isAVoiceChannel ||
|
||||
(this.isAnEmailChannel && !this.inbox.provider) ||
|
||||
this.shouldShowWhatsAppConfiguration ||
|
||||
this.isAWebWidgetInbox
|
||||
@@ -676,7 +683,7 @@ export default {
|
||||
}}
|
||||
</p>
|
||||
</label>
|
||||
<div class="pb-4">
|
||||
<div v-if="!isAVoiceChannel" class="pb-4">
|
||||
<label>
|
||||
{{ $t('INBOX_MGMT.HELP_CENTER.LABEL') }}
|
||||
</label>
|
||||
|
||||
@@ -22,7 +22,6 @@ const state = reactive({
|
||||
authToken: '',
|
||||
apiKeySid: '',
|
||||
apiKeySecret: '',
|
||||
twimlAppSid: '',
|
||||
});
|
||||
|
||||
const uiFlags = useMapGetter('inboxes/getUIFlags');
|
||||
@@ -33,7 +32,6 @@ const validationRules = {
|
||||
authToken: { required },
|
||||
apiKeySid: { required },
|
||||
apiKeySecret: { required },
|
||||
twimlAppSid: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(validationRules, state);
|
||||
@@ -55,9 +53,6 @@ const formErrors = computed(() => ({
|
||||
apiKeySecret: v$.value.apiKeySecret?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.API_KEY_SECRET.REQUIRED')
|
||||
: '',
|
||||
twimlAppSid: v$.value.twimlAppSid?.$error
|
||||
? t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.REQUIRED')
|
||||
: '',
|
||||
}));
|
||||
|
||||
function getProviderConfig() {
|
||||
@@ -67,7 +62,6 @@ function getProviderConfig() {
|
||||
api_key_sid: state.apiKeySid,
|
||||
api_key_secret: state.apiKeySecret,
|
||||
};
|
||||
if (state.twimlAppSid) config.outgoing_application_sid = state.twimlAppSid;
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -160,17 +154,6 @@ async function createChannel() {
|
||||
@blur="v$.apiKeySecret?.$touch"
|
||||
/>
|
||||
|
||||
<Input
|
||||
v-model="state.twimlAppSid"
|
||||
:label="t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.LABEL')"
|
||||
:placeholder="
|
||||
t('INBOX_MGMT.ADD.VOICE.TWILIO.TWIML_APP_SID.PLACEHOLDER')
|
||||
"
|
||||
:message="formErrors.twimlAppSid"
|
||||
:message-type="formErrors.twimlAppSid ? 'error' : 'info'"
|
||||
@blur="v$.twimlAppSid?.$touch"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<NextButton
|
||||
:is-loading="uiFlags.isCreating"
|
||||
|
||||
+19
@@ -126,6 +126,25 @@ export default {
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_VOICE_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_call_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.ADD.VOICE.CONFIGURATION.TWILIO_STATUS_URL_SUBTITLE')
|
||||
"
|
||||
>
|
||||
<woot-code :script="inbox.voice_status_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
|
||||
<div v-else-if="isALineChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
:title="$t('INBOX_MGMT.ADD.LINE_CHANNEL.API_CALLBACK.TITLE')"
|
||||
|
||||
@@ -36,7 +36,6 @@ export const CONVERSATION_PRIORITY_ORDER = {
|
||||
|
||||
// Size in mega bytes
|
||||
export const MAXIMUM_FILE_UPLOAD_SIZE = 40;
|
||||
export const MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL = 5;
|
||||
|
||||
export const ALLOWED_FILE_TYPES =
|
||||
'image/*,' +
|
||||
@@ -50,18 +49,6 @@ export const ALLOWED_FILE_TYPES =
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,' +
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document,';
|
||||
|
||||
export const ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP =
|
||||
'image/png, image/jpeg,' +
|
||||
'audio/mpeg, audio/opus, audio/ogg, audio/amr,' +
|
||||
'video/mp4,' +
|
||||
'application/pdf,';
|
||||
// https://developers.line.biz/en/reference/messaging-api/#image-message, https://developers.line.biz/en/reference/messaging-api/#video-message
|
||||
export const ALLOWED_FILE_TYPES_FOR_LINE = 'image/png, image/jpeg,video/mp4';
|
||||
|
||||
// https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api#requirements
|
||||
export const ALLOWED_FILE_TYPES_FOR_INSTAGRAM =
|
||||
'image/png, image/jpeg, video/mp4, video/mov, video/webm';
|
||||
|
||||
export const CSAT_RATINGS = [
|
||||
{
|
||||
key: 'disappointed',
|
||||
|
||||
@@ -57,15 +57,15 @@ export default {
|
||||
isALineChannel() {
|
||||
return this.channelType === INBOX_TYPES.LINE;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isAnEmailChannel() {
|
||||
return this.channelType === INBOX_TYPES.EMAIL;
|
||||
},
|
||||
isATelegramChannel() {
|
||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||
},
|
||||
isAVoiceChannel() {
|
||||
return this.channelType === INBOX_TYPES.VOICE;
|
||||
},
|
||||
isATwilioSMSChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'sms';
|
||||
|
||||
@@ -150,6 +150,53 @@ const findNextSlot = (currentDay, currentMinutes, openDays) =>
|
||||
})
|
||||
.find(Boolean) || null;
|
||||
|
||||
/**
|
||||
* Convert slot details from inbox timezone to user timezone
|
||||
* @private
|
||||
* @param {Date} time - Current time
|
||||
* @param {string} inboxTz - Inbox timezone
|
||||
* @param {Object} slotDetails - Original slot details
|
||||
* @returns {Object} Slot details with user timezone adjustments
|
||||
*/
|
||||
const convertSlotToUserTimezone = (time, inboxTz, slotDetails) => {
|
||||
if (!slotDetails) return null;
|
||||
|
||||
const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// If timezones match, no conversion needed
|
||||
if (inboxTz === userTz) return slotDetails;
|
||||
|
||||
// Calculate when the slot opens (absolute time)
|
||||
const now = time instanceof Date ? time : new Date(time);
|
||||
const openingTime = new Date(
|
||||
now.getTime() + slotDetails.minutesUntilOpen * 60000
|
||||
);
|
||||
|
||||
// Convert to user timezone
|
||||
const openingInUserTz = getDateInTimezone(openingTime, userTz);
|
||||
const nowInUserTz = getDateInTimezone(now, userTz);
|
||||
|
||||
// Calculate days difference in user timezone
|
||||
const openingDate = new Date(openingInUserTz);
|
||||
openingDate.setHours(0, 0, 0, 0);
|
||||
const todayDate = new Date(nowInUserTz);
|
||||
todayDate.setHours(0, 0, 0, 0);
|
||||
const daysUntilOpen = Math.round((openingDate - todayDate) / 86400000);
|
||||
|
||||
// Return with user timezone adjustments
|
||||
return {
|
||||
...slotDetails,
|
||||
config: {
|
||||
...slotDetails.config,
|
||||
openHour: openingInUserTz.getHours(),
|
||||
openMinutes: openingInUserTz.getMinutes(),
|
||||
dayOfWeek: openingInUserTz.getDay(),
|
||||
},
|
||||
daysUntilOpen,
|
||||
dayOfWeek: openingInUserTz.getDay(),
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -213,6 +260,7 @@ export const isInWorkingHours = (time, utcOffset, workingHours = []) => {
|
||||
|
||||
/**
|
||||
* Find next available slot with detailed information
|
||||
* Returns times adjusted to user's timezone for display
|
||||
* @param {Date|string} time
|
||||
* @param {string} utcOffset
|
||||
* @param {Array} workingHours
|
||||
@@ -238,10 +286,13 @@ export const findNextAvailableSlotDetails = (
|
||||
currentMinutes,
|
||||
openDays
|
||||
);
|
||||
if (todaySlot) return todaySlot;
|
||||
|
||||
// Find next slot
|
||||
return findNextSlot(currentDay, currentMinutes, openDays);
|
||||
// Find the slot (today or next)
|
||||
const slotDetails =
|
||||
todaySlot || findNextSlot(currentDay, currentMinutes, openDays);
|
||||
|
||||
// Convert to user timezone for display
|
||||
return convertSlotToUserTimezone(time, utcOffset, slotDetails);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class Channels::Twilio::TemplatesSyncJob < ApplicationJob
|
||||
queue_as :low
|
||||
|
||||
def perform(twilio_channel)
|
||||
Twilio::TemplateSyncService.new(channel: twilio_channel).call
|
||||
end
|
||||
end
|
||||
@@ -25,6 +25,7 @@
|
||||
# index_categories_on_slug_and_locale_and_portal_id (slug,locale,portal_id) UNIQUE
|
||||
#
|
||||
class Category < ApplicationRecord
|
||||
paginates_per Limits::CATEGORIES_PER_PAGE
|
||||
belongs_to :account
|
||||
belongs_to :portal
|
||||
has_many :folders, dependent: :destroy_async
|
||||
|
||||
@@ -2,16 +2,18 @@
|
||||
#
|
||||
# Table name: channel_twilio_sms
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# account_sid :string not null
|
||||
# api_key_sid :string
|
||||
# auth_token :string not null
|
||||
# medium :integer default("sms")
|
||||
# messaging_service_sid :string
|
||||
# phone_number :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
# id :bigint not null, primary key
|
||||
# account_sid :string not null
|
||||
# api_key_sid :string
|
||||
# auth_token :string not null
|
||||
# content_templates :jsonb
|
||||
# content_templates_last_updated :datetime
|
||||
# medium :integer default("sms")
|
||||
# messaging_service_sid :string
|
||||
# phone_number :string
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :integer not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -7,13 +7,53 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
||||
|
||||
def perform_reply
|
||||
begin
|
||||
twilio_message = channel.send_message(**message_params)
|
||||
twilio_message = if template_params.present?
|
||||
send_template_message
|
||||
else
|
||||
channel.send_message(**message_params)
|
||||
end
|
||||
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
|
||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||
end
|
||||
message.update!(source_id: twilio_message.sid) if twilio_message
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
content_sid, content_variables = process_template_params
|
||||
|
||||
if content_sid.blank?
|
||||
message.update!(status: :failed, external_error: 'Template not found')
|
||||
return nil
|
||||
end
|
||||
|
||||
send_params = {
|
||||
to: contact_inbox.source_id,
|
||||
content_sid: content_sid
|
||||
}
|
||||
|
||||
send_params[:content_variables] = content_variables.to_json if content_variables.present?
|
||||
send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true)
|
||||
|
||||
# Add messaging service or from number
|
||||
send_params = send_params.merge(channel.send(:send_message_from))
|
||||
|
||||
channel.send(:client).messages.create(**send_params)
|
||||
end
|
||||
|
||||
def template_params
|
||||
message.additional_attributes && message.additional_attributes['template_params']
|
||||
end
|
||||
|
||||
def process_template_params
|
||||
return [nil, nil] if template_params.blank?
|
||||
|
||||
Twilio::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: template_params,
|
||||
message: message
|
||||
).call
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
body: message.outgoing_content,
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
class Twilio::TemplateProcessorService
|
||||
pattr_initialize [:channel!, :template_params, :message]
|
||||
|
||||
def call
|
||||
return [nil, nil] if template_params.blank?
|
||||
|
||||
template = find_template
|
||||
return [nil, nil] if template.blank?
|
||||
|
||||
content_variables = build_content_variables(template)
|
||||
[template['content_sid'], content_variables]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_template
|
||||
channel.content_templates&.dig('templates')&.find do |template|
|
||||
template['friendly_name'] == template_params['name'] &&
|
||||
template['language'] == (template_params['language'] || 'en') &&
|
||||
template['status'] == 'approved'
|
||||
end
|
||||
end
|
||||
|
||||
def build_content_variables(template)
|
||||
case template['template_type']
|
||||
when 'text', 'quick_reply'
|
||||
convert_text_template(template_params) # Text and quick reply templates use body variables
|
||||
when 'media'
|
||||
convert_media_template(template_params)
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def convert_text_template(chatwoot_params)
|
||||
return process_key_value_params(chatwoot_params['processed_params']) if chatwoot_params['processed_params'].present?
|
||||
|
||||
process_whatsapp_format_params(chatwoot_params['parameters'])
|
||||
end
|
||||
|
||||
def process_key_value_params(processed_params)
|
||||
content_variables = {}
|
||||
processed_params.each do |key, value|
|
||||
content_variables[key.to_s] = value.to_s
|
||||
end
|
||||
content_variables
|
||||
end
|
||||
|
||||
def process_whatsapp_format_params(parameters)
|
||||
content_variables = {}
|
||||
parameter_index = 1
|
||||
|
||||
parameters&.each do |component|
|
||||
next unless component['type'] == 'body'
|
||||
|
||||
component['parameters']&.each do |param|
|
||||
content_variables[parameter_index.to_s] = param['text']
|
||||
parameter_index += 1
|
||||
end
|
||||
end
|
||||
|
||||
content_variables
|
||||
end
|
||||
|
||||
def convert_media_template(chatwoot_params)
|
||||
content_variables = {}
|
||||
|
||||
# Handle processed_params format (key-value pairs)
|
||||
if chatwoot_params['processed_params'].present?
|
||||
chatwoot_params['processed_params'].each do |key, value|
|
||||
content_variables[key.to_s] = value.to_s
|
||||
end
|
||||
else
|
||||
# Handle parameters format (WhatsApp Cloud API format)
|
||||
parameter_index = 1
|
||||
chatwoot_params['parameters']&.each do |component|
|
||||
parameter_index = process_component(component, content_variables, parameter_index)
|
||||
end
|
||||
end
|
||||
|
||||
content_variables
|
||||
end
|
||||
|
||||
def process_component(component, content_variables, parameter_index)
|
||||
case component['type']
|
||||
when 'header'
|
||||
process_media_header(component, content_variables, parameter_index)
|
||||
when 'body'
|
||||
process_body_parameters(component, content_variables, parameter_index)
|
||||
else
|
||||
parameter_index
|
||||
end
|
||||
end
|
||||
|
||||
def process_media_header(component, content_variables, parameter_index)
|
||||
media_param = component['parameters']&.first
|
||||
return parameter_index unless media_param
|
||||
|
||||
media_link = extract_media_link(media_param)
|
||||
if media_link
|
||||
content_variables[parameter_index.to_s] = media_link
|
||||
parameter_index + 1
|
||||
else
|
||||
parameter_index
|
||||
end
|
||||
end
|
||||
|
||||
def extract_media_link(media_param)
|
||||
media_param.dig('image', 'link') ||
|
||||
media_param.dig('video', 'link') ||
|
||||
media_param.dig('document', 'link')
|
||||
end
|
||||
|
||||
def process_body_parameters(component, content_variables, parameter_index)
|
||||
component['parameters']&.each do |param|
|
||||
content_variables[parameter_index.to_s] = param['text']
|
||||
parameter_index += 1
|
||||
end
|
||||
parameter_index
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,113 @@
|
||||
class Twilio::TemplateSyncService
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
def call
|
||||
fetch_templates_from_twilio
|
||||
update_channel_templates
|
||||
mark_templates_updated
|
||||
rescue Twilio::REST::TwilioError => e
|
||||
Rails.logger.error("Twilio template sync failed: #{e.message}")
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_templates_from_twilio
|
||||
@templates = client.content.v1.contents.list(limit: 1000)
|
||||
end
|
||||
|
||||
def update_channel_templates
|
||||
formatted_templates = @templates.map do |template|
|
||||
{
|
||||
content_sid: template.sid,
|
||||
friendly_name: template.friendly_name,
|
||||
language: template.language,
|
||||
status: derive_status(template),
|
||||
template_type: derive_template_type(template),
|
||||
media_type: derive_media_type(template),
|
||||
variables: template.variables || {},
|
||||
category: derive_category(template),
|
||||
body: extract_body_content(template),
|
||||
created_at: template.date_created,
|
||||
updated_at: template.date_updated
|
||||
}
|
||||
end
|
||||
|
||||
channel.update!(
|
||||
content_templates: { templates: formatted_templates },
|
||||
content_templates_last_updated: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def mark_templates_updated
|
||||
channel.update!(content_templates_last_updated: Time.current)
|
||||
end
|
||||
|
||||
def client
|
||||
@client ||= channel.send(:client)
|
||||
end
|
||||
|
||||
def derive_status(_template)
|
||||
# For now, assume all fetched templates are approved
|
||||
# In the future, this could check approval status from Twilio
|
||||
'approved'
|
||||
end
|
||||
|
||||
def derive_template_type(template)
|
||||
template_types = template.types.keys
|
||||
|
||||
if template_types.include?('twilio/media')
|
||||
'media'
|
||||
elsif template_types.include?('twilio/quick-reply')
|
||||
'quick_reply'
|
||||
elsif template_types.include?('twilio/catalog')
|
||||
'catalog'
|
||||
else
|
||||
'text'
|
||||
end
|
||||
end
|
||||
|
||||
def derive_media_type(template)
|
||||
return nil unless derive_template_type(template) == 'media'
|
||||
|
||||
media_content = template.types['twilio/media']
|
||||
return nil unless media_content
|
||||
|
||||
if media_content['image']
|
||||
'image'
|
||||
elsif media_content['video']
|
||||
'video'
|
||||
elsif media_content['document']
|
||||
'document'
|
||||
end
|
||||
end
|
||||
|
||||
def derive_category(template)
|
||||
# Map template friendly names or other attributes to categories
|
||||
# For now, use utility as default
|
||||
case template.friendly_name
|
||||
when /marketing|promo|offer|sale/i
|
||||
'marketing'
|
||||
when /auth|otp|verify|code/i
|
||||
'authentication'
|
||||
else
|
||||
'utility'
|
||||
end
|
||||
end
|
||||
|
||||
def extract_body_content(template)
|
||||
template_types = template.types
|
||||
|
||||
if template_types['twilio/text']
|
||||
template_types['twilio/text']['body']
|
||||
elsif template_types['twilio/media']
|
||||
template_types['twilio/media']['body']
|
||||
elsif template_types['twilio/quick-reply']
|
||||
template_types['twilio/quick-reply']['body']
|
||||
elsif template_types['twilio/catalog']
|
||||
template_types['twilio/catalog']['body']
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -63,9 +63,12 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||
json.phone_number resource.channel.try(:phone_number)
|
||||
json.medium resource.channel.try(:medium) if resource.twilio?
|
||||
if resource.twilio? && Current.account_user&.administrator?
|
||||
json.auth_token resource.channel.try(:auth_token)
|
||||
json.account_sid resource.channel.try(:account_sid)
|
||||
if resource.twilio?
|
||||
json.content_templates resource.channel.try(:content_templates)
|
||||
if Current.account_user&.administrator?
|
||||
json.auth_token resource.channel.try(:auth_token)
|
||||
json.account_sid resource.channel.try(:account_sid)
|
||||
end
|
||||
end
|
||||
|
||||
if resource.email?
|
||||
@@ -118,3 +121,9 @@ if resource.whatsapp?
|
||||
json.provider_config resource.channel.try(:provider_config) if Current.account_user&.administrator?
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?)
|
||||
end
|
||||
|
||||
## Voice Channel Attributes
|
||||
if resource.channel_type == 'Channel::Voice'
|
||||
json.voice_call_webhook_url resource.channel.try(:voice_call_webhook_url)
|
||||
json.voice_status_webhook_url resource.channel.try(:voice_status_webhook_url)
|
||||
end
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<meta name="msapplication-TileColor" content="#1f93ff">
|
||||
<meta name="msapplication-TileImage" content="/ms-icon-144x144.png">
|
||||
<meta name="theme-color" content="#1f93ff">
|
||||
<meta name="description" content="Chatwoot is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers.Chatwoot also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
|
||||
<meta name="description" content="<%= @global_config['INSTALLATION_NAME'] %> is a customer support solution that helps companies engage customers over Messenger, Twitter, Telegram, WeChat, Whatsapp. Simply connect your channels and converse with your customers from a single place. Easily add new agents to your system and have them own and resolve conversations with customers. <%= @global_config['INSTALLATION_NAME'] %> also gives you real-time reports to measure your team's performance, canned responses to easily respond to frequently asked questions and private notes for agents to collaborate among themselves.">
|
||||
<% if ENV['IOS_APP_IDENTIFIER'].present? %>
|
||||
<meta name="apple-itunes-app" content='app-id=<%= ENV['IOS_APP_IDENTIFIER'] %>'>
|
||||
<% end %>
|
||||
|
||||
@@ -36,7 +36,7 @@ for a collection of resources.
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 h-full min-w-0 relative">
|
||||
<select id="filter-select" class="appearance-none bg-gray-100 border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
|
||||
<select id="filter-select" class="appearance-none bg-transparent border-0 text-gray-700 cursor-pointer text-sm h-full overflow-hidden truncate whitespace-nowrap w-full pr-7 pl-0 py-2 focus:outline-none bg-[url('data:image/svg+xml,%3Csvg xmlns=%27http://www.w3.org/2000/svg%27 width=%2712%27 height=%2712%27 viewBox=%270 0 12 12%27%3E%3Cpath fill=%27%23293f54%27 d=%27M6 9L1 4h10z%27/%3E%3C/svg%3E')] bg-[right_0.25rem_center] bg-no-repeat bg-[length:0.75rem]" onchange="applyFilter(this.value)">
|
||||
<option value="">All records</option>
|
||||
<% dashboard_class::COLLECTION_FILTERS.each do |filter_name, _| %>
|
||||
<option value="<%= filter_name %>" <%= 'selected' if filter_name.to_s == current_filter %>>
|
||||
|
||||
@@ -198,3 +198,6 @@
|
||||
display_name: Assignment V2
|
||||
enabled: false
|
||||
chatwoot_internal: true
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
|
||||
@@ -175,6 +175,10 @@
|
||||
display_title: 'OpenAI API Endpoint (optional)'
|
||||
description: 'The OpenAI endpoint configured for use in Captain AI. Default: https://api.openai.com/'
|
||||
locked: false
|
||||
- name: CAPTAIN_EMBEDDING_MODEL
|
||||
display_title: 'Embedding Model (optional)'
|
||||
description: 'The embedding model configured for use in Captain AI. Default: text-embedding-3-small'
|
||||
locked: false
|
||||
- name: CAPTAIN_FIRECRAWL_API_KEY
|
||||
display_title: 'FireCrawl API Key (optional)'
|
||||
description: 'The FireCrawl API key for the Captain AI service'
|
||||
@@ -215,7 +219,7 @@
|
||||
- name: ANALYTICS_TOKEN
|
||||
value:
|
||||
display_title: 'Analytics Token'
|
||||
description: 'The June.so analytics token for Chatwoot cloud'
|
||||
description: 'The PostHog analytics token for Chatwoot cloud'
|
||||
type: secret
|
||||
- name: CLEARBIT_API_KEY
|
||||
value:
|
||||
|
||||
@@ -376,6 +376,8 @@ en:
|
||||
|
||||
Transcript:
|
||||
%{format_messages}
|
||||
agent_capacity_policy:
|
||||
inbox_already_assigned: 'Inbox has already been assigned to this policy'
|
||||
portals:
|
||||
send_instructions:
|
||||
email_required: 'Email is required'
|
||||
|
||||
@@ -97,6 +97,12 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :sla_policies, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :custom_roles, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :agent_capacity_policies, only: [:index, :create, :show, :update, :destroy] do
|
||||
scope module: :agent_capacity_policies do
|
||||
resources :users, only: [:index, :create, :destroy]
|
||||
resources :inbox_limits, only: [:create, :update, :destroy]
|
||||
end
|
||||
end
|
||||
resources :campaigns, only: [:index, :create, :show, :update, :destroy]
|
||||
resources :dashboard_apps, only: [:index, :show, :create, :update, :destroy]
|
||||
namespace :channels do
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
class AddContentTemplatesToTwilioSms < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :channel_twilio_sms, :content_templates, :jsonb, default: {}
|
||||
add_column :channel_twilio_sms, :content_templates_last_updated, :datetime
|
||||
end
|
||||
end
|
||||
+3
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@@ -474,6 +474,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do
|
||||
t.integer "medium", default: 0
|
||||
t.string "messaging_service_sid"
|
||||
t.string "api_key_sid"
|
||||
t.jsonb "content_templates", default: {}
|
||||
t.datetime "content_templates_last_updated"
|
||||
t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true
|
||||
t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true
|
||||
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
class Api::V1::Accounts::AgentCapacityPolicies::InboxLimitsController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action -> { check_authorization(AgentCapacityPolicy) }
|
||||
before_action :fetch_policy
|
||||
before_action :fetch_inbox, only: [:create]
|
||||
before_action :fetch_inbox_limit, only: [:update, :destroy]
|
||||
before_action :validate_no_duplicate, only: [:create]
|
||||
|
||||
def create
|
||||
@inbox_limit = @agent_capacity_policy.inbox_capacity_limits.create!(
|
||||
inbox: @inbox,
|
||||
conversation_limit: permitted_params[:conversation_limit]
|
||||
)
|
||||
end
|
||||
|
||||
def update
|
||||
@inbox_limit.update!(conversation_limit: permitted_params[:conversation_limit])
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox_limit.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_policy
|
||||
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:agent_capacity_policy_id])
|
||||
end
|
||||
|
||||
def fetch_inbox
|
||||
@inbox = Current.account.inboxes.find(permitted_params[:inbox_id])
|
||||
end
|
||||
|
||||
def fetch_inbox_limit
|
||||
@inbox_limit = @agent_capacity_policy.inbox_capacity_limits.find(params[:id])
|
||||
end
|
||||
|
||||
def validate_no_duplicate
|
||||
return unless @agent_capacity_policy.inbox_capacity_limits.exists?(inbox: @inbox)
|
||||
|
||||
render_could_not_create_error(I18n.t('agent_capacity_policy.inbox_already_assigned'))
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:inbox_id, :conversation_limit)
|
||||
end
|
||||
end
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
class Api::V1::Accounts::AgentCapacityPolicies::UsersController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action -> { check_authorization(AgentCapacityPolicy) }
|
||||
before_action :fetch_policy
|
||||
before_action :fetch_user, only: [:destroy]
|
||||
|
||||
def index
|
||||
@users = Current.account.users.joins(:account_users)
|
||||
.where(account_users: { agent_capacity_policy_id: @agent_capacity_policy.id })
|
||||
end
|
||||
|
||||
def create
|
||||
@account_user = Current.account.account_users.find_by!(user_id: permitted_params[:user_id])
|
||||
@account_user.update!(agent_capacity_policy: @agent_capacity_policy)
|
||||
@user = @account_user.user
|
||||
end
|
||||
|
||||
def destroy
|
||||
@account_user.update!(agent_capacity_policy: nil)
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_policy
|
||||
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:agent_capacity_policy_id])
|
||||
end
|
||||
|
||||
def fetch_user
|
||||
@account_user = Current.account.account_users.find_by!(user_id: params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:user_id)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,37 @@
|
||||
class Api::V1::Accounts::AgentCapacityPoliciesController < Api::V1::Accounts::EnterpriseAccountsController
|
||||
before_action :check_authorization
|
||||
before_action :fetch_policy, only: [:show, :update, :destroy]
|
||||
|
||||
def index
|
||||
@agent_capacity_policies = Current.account.agent_capacity_policies
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@agent_capacity_policy = Current.account.agent_capacity_policies.create!(permitted_params)
|
||||
end
|
||||
|
||||
def update
|
||||
@agent_capacity_policy.update!(permitted_params)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@agent_capacity_policy.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def permitted_params
|
||||
params.require(:agent_capacity_policy).permit(
|
||||
:name,
|
||||
:description,
|
||||
exclusion_rules: [:overall_capacity, { hours: [], days: [] }]
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_policy
|
||||
@agent_capacity_policy = Current.account.agent_capacity_policies.find(params[:id])
|
||||
end
|
||||
end
|
||||
@@ -4,7 +4,7 @@ class Captain::Documents::ResponseBuilderJob < ApplicationJob
|
||||
def perform(document)
|
||||
reset_previous_responses(document)
|
||||
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content).generate
|
||||
faqs = Captain::Llm::FaqGeneratorService.new(document.content, document.account.locale_english_name).generate
|
||||
faqs.each do |faq|
|
||||
create_response(faq, document)
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: agent_capacity_policies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# description :text
|
||||
# exclusion_rules :jsonb not null
|
||||
# name :string(255) not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_agent_capacity_policies_on_account_id (account_id)
|
||||
#
|
||||
class AgentCapacityPolicy < ApplicationRecord
|
||||
MAX_NAME_LENGTH = 255
|
||||
|
||||
belongs_to :account
|
||||
has_many :inbox_capacity_limits, dependent: :destroy
|
||||
has_many :inboxes, through: :inbox_capacity_limits
|
||||
has_many :account_users, dependent: :nullify
|
||||
|
||||
validates :name, presence: true, length: { maximum: MAX_NAME_LENGTH }
|
||||
validates :account, presence: true
|
||||
end
|
||||
@@ -30,6 +30,7 @@ class Channel::Voice < ApplicationRecord
|
||||
|
||||
# Provider-specific configs stored in JSON
|
||||
validate :validate_provider_config
|
||||
before_validation :provision_twilio_on_create, on: :create, if: :twilio?
|
||||
|
||||
EDITABLE_ATTRS = [:phone_number, :provider, { provider_config: {} }].freeze
|
||||
|
||||
@@ -41,8 +42,23 @@ class Channel::Voice < ApplicationRecord
|
||||
false
|
||||
end
|
||||
|
||||
# Public URLs used to configure Twilio webhooks
|
||||
def voice_call_webhook_url
|
||||
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
|
||||
"#{base}/twilio/voice/call/#{phone_number}"
|
||||
end
|
||||
|
||||
def voice_status_webhook_url
|
||||
base = ENV.fetch('FRONTEND_URL', '').to_s.sub(%r{/*$}, '')
|
||||
"#{base}/twilio/voice/status/#{phone_number}"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio?
|
||||
provider == 'twilio'
|
||||
end
|
||||
|
||||
def validate_provider_config
|
||||
return if provider_config.blank?
|
||||
|
||||
@@ -54,10 +70,30 @@ class Channel::Voice < ApplicationRecord
|
||||
|
||||
def validate_twilio_config
|
||||
config = provider_config.with_indifferent_access
|
||||
required_keys = %w[account_sid auth_token api_key_sid api_key_secret]
|
||||
|
||||
# Require credentials and provisioned TwiML App SID
|
||||
required_keys = %w[account_sid auth_token api_key_sid api_key_secret twiml_app_sid]
|
||||
required_keys.each do |key|
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
|
||||
def provision_twilio_on_create
|
||||
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||
app_sid = service.perform
|
||||
return if app_sid.blank?
|
||||
|
||||
cfg = provider_config.with_indifferent_access
|
||||
cfg[:twiml_app_sid] = app_sid
|
||||
self.provider_config = cfg
|
||||
rescue StandardError => e
|
||||
error_details = {
|
||||
error_class: e.class.to_s,
|
||||
message: e.message,
|
||||
phone_number: phone_number,
|
||||
account_id: account_id,
|
||||
backtrace: e.backtrace&.first(5)
|
||||
}
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
|
||||
errors.add(:base, "Twilio setup failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ module Enterprise::Concerns::Account
|
||||
has_many :sla_policies, dependent: :destroy_async
|
||||
has_many :applied_slas, dependent: :destroy_async
|
||||
has_many :custom_roles, dependent: :destroy_async
|
||||
has_many :agent_capacity_policies, dependent: :destroy_async
|
||||
|
||||
has_many :captain_assistants, dependent: :destroy_async, class_name: 'Captain::Assistant'
|
||||
has_many :captain_assistant_responses, dependent: :destroy_async, class_name: 'Captain::AssistantResponse'
|
||||
|
||||
@@ -3,5 +3,6 @@ module Enterprise::Concerns::AccountUser
|
||||
|
||||
included do
|
||||
belongs_to :custom_role, optional: true
|
||||
belongs_to :agent_capacity_policy, optional: true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: inbox_capacity_limits
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# conversation_limit :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# agent_capacity_policy_id :bigint not null
|
||||
# inbox_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# idx_on_agent_capacity_policy_id_inbox_id_71c7ec4caf (agent_capacity_policy_id,inbox_id) UNIQUE
|
||||
# index_inbox_capacity_limits_on_agent_capacity_policy_id (agent_capacity_policy_id)
|
||||
# index_inbox_capacity_limits_on_inbox_id (inbox_id)
|
||||
#
|
||||
class InboxCapacityLimit < ApplicationRecord
|
||||
belongs_to :agent_capacity_policy
|
||||
belongs_to :inbox
|
||||
|
||||
validates :conversation_limit, presence: true, numericality: { greater_than: 0, only_integer: true }
|
||||
validates :inbox_id, uniqueness: { scope: :agent_capacity_policy_id }
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
class AgentCapacityPolicyPolicy < ApplicationPolicy
|
||||
def index?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def create?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def show?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def update?
|
||||
@account_user.administrator?
|
||||
end
|
||||
|
||||
def destroy?
|
||||
@account_user.administrator?
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,11 @@ require 'openai'
|
||||
class Captain::Llm::EmbeddingService < Llm::BaseOpenAiService
|
||||
class EmbeddingsError < StandardError; end
|
||||
|
||||
DEFAULT_MODEL = 'text-embedding-3-small'.freeze
|
||||
def self.embedding_model
|
||||
@embedding_model = InstallationConfig.find_by(name: 'CAPTAIN_EMBEDDING_MODEL')&.value.presence || OpenAiConstants::DEFAULT_EMBEDDING_MODEL
|
||||
end
|
||||
|
||||
def get_embedding(content, model: DEFAULT_MODEL)
|
||||
def get_embedding(content, model: self.class.embedding_model)
|
||||
response = @client.embeddings(
|
||||
parameters: {
|
||||
model: model,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
|
||||
def initialize(content)
|
||||
def initialize(content, language = 'english')
|
||||
super()
|
||||
@language = language
|
||||
@content = content
|
||||
end
|
||||
|
||||
@@ -14,10 +15,10 @@ class Captain::Llm::FaqGeneratorService < Llm::BaseOpenAiService
|
||||
|
||||
private
|
||||
|
||||
attr_reader :content
|
||||
attr_reader :content, :language
|
||||
|
||||
def chat_parameters
|
||||
prompt = Captain::Llm::SystemPromptsService.faq_generator
|
||||
prompt = Captain::Llm::SystemPromptsService.faq_generator(language)
|
||||
{
|
||||
model: @model,
|
||||
response_format: { type: 'json_object' },
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
class Captain::Llm::SystemPromptsService
|
||||
class << self
|
||||
def faq_generator
|
||||
def faq_generator(language = 'english')
|
||||
<<~PROMPT
|
||||
You are a content writer looking to convert user content into short FAQs which can be added to your website's help center.
|
||||
Format the webpage content provided in the message to FAQ format mentioned below in the JSON format.
|
||||
Ensure that you only generate faqs from the information provided only.
|
||||
Ensure that output is always valid json.
|
||||
You are a content writer specializing in creating good FAQ sections for website help centers. Your task is to convert provided content into a structured FAQ format without losing any information.
|
||||
|
||||
## Core Requirements
|
||||
|
||||
**Completeness**: Extract ALL information from the source content. Every detail, example, procedure, and explanation must be captured across the FAQ set. When combined, the FAQs should reconstruct the original content entirely.
|
||||
|
||||
**Accuracy**: Base answers strictly on the provided text. Do not add assumptions, interpretations, or external knowledge not present in the source material.
|
||||
|
||||
**Structure**: Format output as valid JSON using this exact structure:
|
||||
|
||||
**Language**: Generate the FAQs only in the #{language}, use no other language
|
||||
|
||||
If no match is available, return an empty JSON.
|
||||
```json
|
||||
{ faqs: [ { question: '', answer: ''} ]
|
||||
{
|
||||
"faqs": [
|
||||
{
|
||||
"question": "Clear, specific question based on content",
|
||||
"answer": "Complete answer containing all relevant details from source"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Guidelines
|
||||
|
||||
- **Question Creation**: Formulate questions that naturally arise from the content (What is...? How do I...? When should...? Why does...?). Do not generate questions that are not related to the content.
|
||||
- **Answer Completeness**: Include all relevant details, steps, examples, and context from the original content
|
||||
- **Information Preservation**: Ensure no examples, procedures, warnings, or explanatory details are omitted
|
||||
- **JSON Validity**: Always return properly formatted, valid JSON
|
||||
- **No Content Scenario**: If no suitable content is found, return: `{"faqs": []}`
|
||||
|
||||
## Process
|
||||
1. Read the entire provided content carefully
|
||||
2. Identify all key information points, procedures, and examples
|
||||
3. Create questions that cover each information point
|
||||
4. Write comprehensive short answers that capture all related detail, include bullet points if needed.
|
||||
5. Verify that combined FAQs represent the complete original content.
|
||||
6. Format as valid JSON
|
||||
PROMPT
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
class Twilio::VoiceWebhookSetupService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
HTTP_METHOD = 'POST'.freeze
|
||||
|
||||
# Returns created TwiML App SID on success.
|
||||
def perform
|
||||
validate_token_credentials!
|
||||
|
||||
app_sid = create_twiml_app!
|
||||
configure_number_webhooks!
|
||||
app_sid
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def validate_token_credentials!
|
||||
# Only validate Account SID + Auth Token
|
||||
token_client.incoming_phone_numbers.list(limit: 1)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('AUTH_VALIDATION_TOKEN', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def create_twiml_app!
|
||||
friendly_name = "Chatwoot Voice #{channel.phone_number}"
|
||||
app = api_key_client.applications.create(
|
||||
friendly_name: friendly_name,
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD
|
||||
)
|
||||
app.sid
|
||||
rescue StandardError => e
|
||||
log_twilio_error('TWIML_APP_CREATE', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def configure_number_webhooks!
|
||||
numbers = api_key_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
if numbers.empty?
|
||||
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
|
||||
return
|
||||
end
|
||||
|
||||
api_key_client
|
||||
.incoming_phone_numbers(numbers.first.sid)
|
||||
.update(
|
||||
voice_url: channel.voice_call_webhook_url,
|
||||
voice_method: HTTP_METHOD,
|
||||
status_callback: channel.voice_status_webhook_url,
|
||||
status_callback_method: HTTP_METHOD
|
||||
)
|
||||
rescue StandardError => e
|
||||
log_twilio_error('NUMBER_WEBHOOKS_UPDATE', e)
|
||||
raise
|
||||
end
|
||||
|
||||
def api_key_client
|
||||
@api_key_client ||= begin
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
::Twilio::REST::Client.new(cfg[:api_key_sid], cfg[:api_key_secret], cfg[:account_sid])
|
||||
end
|
||||
end
|
||||
|
||||
def token_client
|
||||
@token_client ||= begin
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
::Twilio::REST::Client.new(cfg[:account_sid], cfg[:auth_token])
|
||||
end
|
||||
end
|
||||
|
||||
def log_twilio_error(context, error)
|
||||
details = build_error_details(context, error)
|
||||
add_twilio_specific_details(details, error)
|
||||
|
||||
backtrace = error.backtrace.is_a?(Array) ? error.backtrace.first(5) : []
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ERROR: #{details} backtrace=#{backtrace}")
|
||||
end
|
||||
|
||||
def build_error_details(context, error)
|
||||
cfg = channel.provider_config.with_indifferent_access
|
||||
{
|
||||
context: context,
|
||||
phone_number: channel.phone_number,
|
||||
account_sid: cfg[:account_sid],
|
||||
error_class: error.class.to_s,
|
||||
message: error.message
|
||||
}
|
||||
end
|
||||
|
||||
def add_twilio_specific_details(details, error)
|
||||
details[:status_code] = error.status_code if error.respond_to?(:status_code)
|
||||
details[:twilio_code] = error.code if error.respond_to?(:code)
|
||||
details[:more_info] = error.more_info if error.respond_to?(:more_info)
|
||||
details[:details] = error.details if error.respond_to?(:details)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
json.partial! 'api/v1/models/agent_capacity_policy', formats: [:json],
|
||||
agent_capacity_policy: @agent_capacity_policy
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
json.id @inbox_limit.id
|
||||
json.inbox_id @inbox_limit.inbox_id
|
||||
json.agent_capacity_policy_id @inbox_limit.agent_capacity_policy_id
|
||||
json.conversation_limit @inbox_limit.conversation_limit
|
||||
json.created_at @inbox_limit.created_at.to_i
|
||||
json.updated_at @inbox_limit.updated_at.to_i
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
json.id @inbox_limit.id
|
||||
json.inbox_id @inbox_limit.inbox_id
|
||||
json.inbox_name @inbox_limit.inbox.name
|
||||
json.agent_capacity_policy_id @agent_capacity_policy.id
|
||||
json.conversation_limit @inbox_limit.conversation_limit
|
||||
json.created_at @inbox_limit.created_at.to_i
|
||||
json.updated_at @inbox_limit.updated_at.to_i
|
||||
@@ -0,0 +1,4 @@
|
||||
json.array! @agent_capacity_policies do |policy|
|
||||
json.partial! 'api/v1/models/agent_capacity_policy', formats: [:json],
|
||||
agent_capacity_policy: policy
|
||||
end
|
||||
@@ -0,0 +1,2 @@
|
||||
json.partial! 'api/v1/models/agent_capacity_policy', formats: [:json],
|
||||
agent_capacity_policy: @agent_capacity_policy
|
||||
@@ -0,0 +1,2 @@
|
||||
json.partial! 'api/v1/models/agent_capacity_policy', formats: [:json],
|
||||
agent_capacity_policy: @agent_capacity_policy
|
||||
+1
@@ -0,0 +1 @@
|
||||
json.partial! 'api/v1/models/user', resource: @user
|
||||
@@ -0,0 +1,3 @@
|
||||
json.array! @users do |user|
|
||||
json.partial! 'api/v1/models/user', resource: user
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
json.id agent_capacity_policy.id
|
||||
json.name agent_capacity_policy.name
|
||||
json.description agent_capacity_policy.description
|
||||
json.exclusion_rules agent_capacity_policy.exclusion_rules
|
||||
json.created_at agent_capacity_policy.created_at.to_i
|
||||
json.updated_at agent_capacity_policy.updated_at.to_i
|
||||
json.account_id agent_capacity_policy.account_id
|
||||
|
||||
json.inbox_capacity_limits agent_capacity_policy.inbox_capacity_limits do |limit|
|
||||
json.id limit.id
|
||||
json.inbox_id limit.inbox_id
|
||||
json.conversation_limit limit.conversation_limit
|
||||
end
|
||||
@@ -4,6 +4,7 @@ module Limits
|
||||
URL_LENGTH_LIMIT = 2048 # https://stackoverflow.com/questions/417142
|
||||
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
|
||||
GREETING_MESSAGE_MAX_LENGTH = 10_000
|
||||
CATEGORIES_PER_PAGE = 1000
|
||||
|
||||
def self.conversation_message_per_minute_limit
|
||||
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
|
||||
|
||||
@@ -3,4 +3,5 @@
|
||||
module OpenAiConstants
|
||||
DEFAULT_MODEL = 'gpt-4.1-mini'
|
||||
DEFAULT_ENDPOINT = 'https://api.openai.com'
|
||||
DEFAULT_EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
end
|
||||
|
||||
+2
-2
@@ -34,13 +34,12 @@
|
||||
"@breezystack/lamejs": "^1.2.7",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.2.1",
|
||||
"@chatwoot/utils": "^0.0.49",
|
||||
"@chatwoot/utils": "^0.0.50",
|
||||
"@formkit/core": "^1.6.7",
|
||||
"@formkit/vue": "^1.6.7",
|
||||
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
|
||||
"@highlightjs/vue-plugin": "^2.1.0",
|
||||
"@iconify-json/material-symbols": "^1.2.10",
|
||||
"@june-so/analytics-next": "^2.0.0",
|
||||
"@lk77/vue3-color": "^3.0.6",
|
||||
"@radix-ui/colors": "^3.0.0",
|
||||
"@rails/actioncable": "6.1.3",
|
||||
@@ -80,6 +79,7 @@
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"posthog-js": "^1.260.2",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
|
||||
Generated
+39
-1303
File diff suppressed because it is too large
Load Diff
+80
@@ -0,0 +1,80 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agent Capacity Policy Inbox Limits API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits' do
|
||||
context 'when not admin' do
|
||||
it 'requires admin role' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
|
||||
params: { inbox_id: inbox.id, conversation_limit: 10 },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when admin' do
|
||||
it 'creates an inbox limit' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
|
||||
params: { inbox_id: inbox.id, conversation_limit: 10 },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
json_response = response.parsed_body
|
||||
expect(json_response['conversation_limit']).to eq(10)
|
||||
expect(json_response['inbox_id']).to eq(inbox.id)
|
||||
end
|
||||
|
||||
it 'prevents duplicate inbox assignments' do
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox)
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits",
|
||||
params: { inbox_id: inbox.id, conversation_limit: 10 },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq(I18n.t('agent_capacity_policy.inbox_already_assigned'))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits/{id}' do
|
||||
let!(:inbox_limit) { create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox, conversation_limit: 5) }
|
||||
|
||||
context 'when admin' do
|
||||
it 'updates the inbox limit' do
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits/#{inbox_limit.id}",
|
||||
params: { conversation_limit: 15 },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['conversation_limit']).to eq(15)
|
||||
expect(inbox_limit.reload.conversation_limit).to eq(15)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/inbox_limits/{id}' do
|
||||
let!(:inbox_limit) { create(:inbox_capacity_limit, agent_capacity_policy: agent_capacity_policy, inbox: inbox) }
|
||||
|
||||
context 'when admin' do
|
||||
it 'removes the inbox limit' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/inbox_limits/#{inbox_limit.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:no_content)
|
||||
expect(agent_capacity_policy.inbox_capacity_limits.find_by(id: inbox_limit.id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agent Capacity Policy Users API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
let!(:user) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users' do
|
||||
context 'when admin' do
|
||||
it 'returns assigned users' do
|
||||
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.first['id']).to eq(user.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users' do
|
||||
context 'when not admin' do
|
||||
it 'requires admin role' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
params: { user_id: user.id },
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when admin' do
|
||||
it 'assigns user to the policy' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users",
|
||||
params: { user_id: user.id },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(user.account_users.first.reload.agent_capacity_policy).to eq(agent_capacity_policy)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{policy_id}/users/{id}' do
|
||||
context 'when admin' do
|
||||
before do
|
||||
user.account_users.first.update!(agent_capacity_policy: agent_capacity_policy)
|
||||
end
|
||||
|
||||
it 'removes user from the policy' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}/users/#{user.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.account_users.first.reload.agent_capacity_policy).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Agent Capacity Policies API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let!(:agent_capacity_policy) { create(:agent_capacity_policy, account: account) }
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns all agent capacity policies' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body.first['id']).to eq(agent_capacity_policy.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'GET /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns the agent capacity policy' do
|
||||
get "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['id']).to eq(agent_capacity_policy.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/agent_capacity_policies' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
params = { agent_capacity_policy: { name: 'Test Policy' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new agent capacity policy when administrator' do
|
||||
params = {
|
||||
agent_capacity_policy: {
|
||||
name: 'Test Policy',
|
||||
description: 'Test Description',
|
||||
exclusion_rules: { overall_capacity: 10 }
|
||||
}
|
||||
}
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['name']).to eq('Test Policy')
|
||||
expect(response.parsed_body['description']).to eq('Test Description')
|
||||
end
|
||||
|
||||
it 'returns validation errors for invalid data' do
|
||||
params = { agent_capacity_policy: { name: '' } }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/agent_capacity_policies",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PUT /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
params = { agent_capacity_policy: { name: 'Updated Policy' } }
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
params: params,
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'updates the agent capacity policy when administrator' do
|
||||
params = { agent_capacity_policy: { name: 'Updated Policy' } }
|
||||
|
||||
put "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
params: params,
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['name']).to eq('Updated Policy')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /api/v1/accounts/{account.id}/agent_capacity_policies/{id}' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'deletes the agent capacity policy when administrator' do
|
||||
delete "/api/v1/accounts/#{account.id}/agent_capacity_policies/#{agent_capacity_policy.id}",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect { agent_capacity_policy.reload }.to raise_error(ActiveRecord::RecordNotFound)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,6 +24,9 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
end
|
||||
|
||||
it 'creates a voice inbox when administrator' do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService,
|
||||
perform: "AP#{SecureRandom.hex(16)}"))
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/inboxes",
|
||||
headers: admin.create_new_auth_token,
|
||||
params: { name: 'Voice Inbox',
|
||||
@@ -31,7 +34,8 @@ RSpec.describe 'Enterprise Inboxes API', type: :request do
|
||||
provider_config: { account_sid: "AC#{SecureRandom.hex(16)}",
|
||||
auth_token: SecureRandom.hex(16),
|
||||
api_key_sid: SecureRandom.hex(8),
|
||||
api_key_secret: SecureRandom.hex(16) } } },
|
||||
api_key_secret: SecureRandom.hex(16),
|
||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}" } } },
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
|
||||
@@ -13,7 +13,7 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
|
||||
.with(document.content)
|
||||
.with(document.content, document.account.locale_english_name)
|
||||
.and_return(faq_generator)
|
||||
allow(faq_generator).to receive(:generate).and_return(faqs)
|
||||
end
|
||||
@@ -43,5 +43,26 @@ RSpec.describe Captain::Documents::ResponseBuilderJob, type: :job do
|
||||
expect(first_response.documentable).to eq(document)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different locales' do
|
||||
let(:spanish_account) { create(:account, locale: 'pt') }
|
||||
let(:spanish_assistant) { create(:captain_assistant, account: spanish_account) }
|
||||
let(:spanish_document) { create(:captain_document, assistant: spanish_assistant, account: spanish_account) }
|
||||
let(:spanish_faq_generator) { instance_double(Captain::Llm::FaqGeneratorService) }
|
||||
|
||||
before do
|
||||
allow(Captain::Llm::FaqGeneratorService).to receive(:new)
|
||||
.with(spanish_document.content, 'portuguese')
|
||||
.and_return(spanish_faq_generator)
|
||||
allow(spanish_faq_generator).to receive(:generate).and_return(faqs)
|
||||
end
|
||||
|
||||
it 'passes the correct locale to FAQ generator' do
|
||||
described_class.new.perform(spanish_document)
|
||||
|
||||
expect(Captain::Llm::FaqGeneratorService).to have_received(:new)
|
||||
.with(spanish_document.content, 'portuguese')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AgentCapacityPolicy, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_length_of(:name).is_at_most(255) }
|
||||
end
|
||||
|
||||
describe 'destruction' do
|
||||
let(:policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
it 'destroys associated inbox capacity limits' do
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
expect { policy.destroy }.to change(InboxCapacityLimit, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'nullifies associated account users' do
|
||||
account_user = user.account_users.first
|
||||
account_user.update!(agent_capacity_policy: policy)
|
||||
|
||||
policy.destroy
|
||||
expect(account_user.reload.agent_capacity_policy).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -3,8 +3,13 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::Voice do
|
||||
let(:twiml_app_sid) { 'AP1234567890abcdef' }
|
||||
let(:channel) { create(:channel_voice) }
|
||||
|
||||
before do
|
||||
allow(Twilio::VoiceWebhookSetupService).to receive(:new).and_return(instance_double(Twilio::VoiceWebhookSetupService, perform: twiml_app_sid))
|
||||
end
|
||||
|
||||
it 'has a valid factory' do
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
@@ -40,12 +45,19 @@ RSpec.describe Channel::Voice do
|
||||
expect(channel.errors[:provider_config]).to include('api_key_secret is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'validates presence of twiml_app_sid in provider_config' do
|
||||
channel.provider_config = { account_sid: 'sid', auth_token: 'token', api_key_sid: 'key', api_key_secret: 'secret' }
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:provider_config]).to include('twiml_app_sid is required for Twilio provider')
|
||||
end
|
||||
|
||||
it 'is valid with all required provider_config fields' do
|
||||
channel.provider_config = {
|
||||
account_sid: 'test_sid',
|
||||
auth_token: 'test_token',
|
||||
api_key_sid: 'test_key',
|
||||
api_key_secret: 'test_secret'
|
||||
api_key_secret: 'test_secret',
|
||||
twiml_app_sid: 'test_app_sid'
|
||||
}
|
||||
expect(channel).to be_valid
|
||||
end
|
||||
@@ -57,4 +69,11 @@ RSpec.describe Channel::Voice do
|
||||
expect(channel.name).to include(channel.phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'provisioning on create' do
|
||||
it 'stores twiml_app_sid in provider_config' do
|
||||
ch = create(:channel_voice)
|
||||
expect(ch.provider_config.with_indifferent_access[:twiml_app_sid]).to eq(twiml_app_sid)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InboxCapacityLimit, type: :model do
|
||||
let(:account) { create(:account) }
|
||||
let(:policy) { create(:agent_capacity_policy, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
|
||||
describe 'validations' do
|
||||
subject { create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox) }
|
||||
|
||||
it { is_expected.to validate_presence_of(:conversation_limit) }
|
||||
it { is_expected.to validate_numericality_of(:conversation_limit).is_greater_than(0).only_integer }
|
||||
it { is_expected.to validate_uniqueness_of(:inbox_id).scoped_to(:agent_capacity_policy_id) }
|
||||
end
|
||||
|
||||
describe 'uniqueness constraint' do
|
||||
it 'prevents duplicate inbox limits for the same policy' do
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
duplicate = build(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
|
||||
expect(duplicate).not_to be_valid
|
||||
expect(duplicate.errors[:inbox_id]).to include('has already been taken')
|
||||
end
|
||||
|
||||
it 'allows the same inbox in different policies' do
|
||||
other_policy = create(:agent_capacity_policy, account: account)
|
||||
create(:inbox_capacity_limit, agent_capacity_policy: policy, inbox: inbox)
|
||||
|
||||
different_policy_limit = build(:inbox_capacity_limit, agent_capacity_policy: other_policy, inbox: inbox)
|
||||
expect(different_policy_limit).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,87 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Captain::Llm::FaqGeneratorService do
|
||||
let(:content) { 'Sample content for FAQ generation' }
|
||||
let(:language) { 'english' }
|
||||
let(:service) { described_class.new(content, language) }
|
||||
let(:client) { instance_double(OpenAI::Client) }
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'CAPTAIN_OPEN_AI_API_KEY', value: 'test-key')
|
||||
allow(OpenAI::Client).to receive(:new).and_return(client)
|
||||
end
|
||||
|
||||
describe '#generate' do
|
||||
let(:sample_faqs) do
|
||||
[
|
||||
{ 'question' => 'What is this service?', 'answer' => 'It generates FAQs.' },
|
||||
{ 'question' => 'How does it work?', 'answer' => 'Using AI technology.' }
|
||||
]
|
||||
end
|
||||
|
||||
let(:openai_response) do
|
||||
{
|
||||
'choices' => [
|
||||
{
|
||||
'message' => {
|
||||
'content' => { faqs: sample_faqs }.to_json
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
context 'when successful' do
|
||||
before do
|
||||
allow(client).to receive(:chat).and_return(openai_response)
|
||||
allow(Captain::Llm::SystemPromptsService).to receive(:faq_generator).and_return('system prompt')
|
||||
end
|
||||
|
||||
it 'returns parsed FAQs' do
|
||||
result = service.generate
|
||||
expect(result).to eq(sample_faqs)
|
||||
end
|
||||
|
||||
it 'calls OpenAI client with chat parameters' do
|
||||
expect(client).to receive(:chat).with(parameters: hash_including(
|
||||
model: 'gpt-4o-mini',
|
||||
response_format: { type: 'json_object' },
|
||||
messages: array_including(
|
||||
hash_including(role: 'system'),
|
||||
hash_including(role: 'user', content: content)
|
||||
)
|
||||
))
|
||||
service.generate
|
||||
end
|
||||
|
||||
it 'calls SystemPromptsService with correct language' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with(language)
|
||||
service.generate
|
||||
end
|
||||
end
|
||||
|
||||
context 'with different language' do
|
||||
let(:language) { 'spanish' }
|
||||
|
||||
before do
|
||||
allow(client).to receive(:chat).and_return(openai_response)
|
||||
end
|
||||
|
||||
it 'passes the correct language to SystemPromptsService' do
|
||||
expect(Captain::Llm::SystemPromptsService).to receive(:faq_generator).with('spanish')
|
||||
service.generate
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OpenAI API fails' do
|
||||
before do
|
||||
allow(client).to receive(:chat).and_raise(OpenAI::Error.new('API Error'))
|
||||
end
|
||||
|
||||
it 'handles the error and returns empty array' do
|
||||
expect(Rails.logger).to receive(:error).with('OpenAI API Error: API Error')
|
||||
expect(service.generate).to eq([])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Twilio::VoiceWebhookSetupService do
|
||||
let(:account_sid) { 'ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }
|
||||
let(:auth_token) { 'auth_token_123' }
|
||||
let(:api_key_sid) { 'SKaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }
|
||||
let(:api_key_secret) { 'api_key_secret_123' }
|
||||
let(:phone_number) { '+15551230001' }
|
||||
let(:frontend_url) { 'https://app.chatwoot.test' }
|
||||
|
||||
let(:channel) do
|
||||
build(:channel_voice, phone_number: phone_number, provider_config: {
|
||||
account_sid: account_sid,
|
||||
auth_token: auth_token,
|
||||
api_key_sid: api_key_sid,
|
||||
api_key_secret: api_key_secret
|
||||
})
|
||||
end
|
||||
|
||||
let(:twilio_base_url) { "https://api.twilio.com/2010-04-01/Accounts/#{account_sid}" }
|
||||
let(:incoming_numbers_url) { "#{twilio_base_url}/IncomingPhoneNumbers.json" }
|
||||
let(:applications_url) { "#{twilio_base_url}/Applications.json" }
|
||||
let(:phone_number_sid) { 'PN123' }
|
||||
let(:phone_number_url) { "#{twilio_base_url}/IncomingPhoneNumbers/#{phone_number_sid}.json" }
|
||||
|
||||
before do
|
||||
# Token validation using Account SID + Auth Token
|
||||
stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/)
|
||||
.with(basic_auth: [account_sid, auth_token])
|
||||
.to_return(status: 200,
|
||||
body: { incoming_phone_numbers: [], meta: { key: 'incoming_phone_numbers' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# Number lookup using API Key SID/Secret
|
||||
stub_request(:get, /#{Regexp.escape(incoming_numbers_url)}.*/)
|
||||
.with(basic_auth: [api_key_sid, api_key_secret])
|
||||
.to_return(status: 200,
|
||||
body: { incoming_phone_numbers: [{ sid: phone_number_sid }], meta: { key: 'incoming_phone_numbers' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# TwiML App create (voice only)
|
||||
stub_request(:post, applications_url)
|
||||
.with(basic_auth: [api_key_sid, api_key_secret])
|
||||
.to_return(status: 201,
|
||||
body: { sid: 'AP123' }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# Incoming Phone Number webhook update
|
||||
stub_request(:post, phone_number_url)
|
||||
.with(basic_auth: [api_key_sid, api_key_secret])
|
||||
.to_return(status: 200,
|
||||
body: { sid: phone_number_sid }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'creates a TwiML App and configures number webhooks with correct URLs' do
|
||||
with_modified_env FRONTEND_URL: frontend_url do
|
||||
service = described_class.new(channel: channel)
|
||||
sid = service.perform
|
||||
|
||||
expect(sid).to eq('AP123')
|
||||
|
||||
expected_voice_url = channel.voice_call_webhook_url
|
||||
expected_status_url = channel.voice_status_webhook_url
|
||||
|
||||
# Assert TwiML App creation body includes voice URL and POST method
|
||||
expect(
|
||||
a_request(:post, applications_url)
|
||||
.with(body: hash_including('VoiceUrl' => expected_voice_url, 'VoiceMethod' => 'POST'))
|
||||
).to have_been_made
|
||||
|
||||
# Assert number webhook update body includes both URLs and POST methods
|
||||
expect(
|
||||
a_request(:post, phone_number_url)
|
||||
.with(
|
||||
body: hash_including(
|
||||
'VoiceUrl' => expected_voice_url,
|
||||
'VoiceMethod' => 'POST',
|
||||
'StatusCallback' => expected_status_url,
|
||||
'StatusCallbackMethod' => 'POST'
|
||||
)
|
||||
)
|
||||
).to have_been_made
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,21 @@
|
||||
FactoryBot.define do
|
||||
factory :agent_capacity_policy do
|
||||
account
|
||||
sequence(:name) { |n| "Agent Capacity Policy #{n}" }
|
||||
description { 'Test agent capacity policy' }
|
||||
exclusion_rules { {} }
|
||||
|
||||
trait :with_overall_capacity do
|
||||
exclusion_rules { { 'overall_capacity' => 10 } }
|
||||
end
|
||||
|
||||
trait :with_time_exclusions do
|
||||
exclusion_rules do
|
||||
{
|
||||
'hours' => [0, 1, 2, 3, 4, 5],
|
||||
'days' => %w[saturday sunday]
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,8 @@ FactoryBot.define do
|
||||
account_sid: "AC#{SecureRandom.hex(16)}",
|
||||
auth_token: SecureRandom.hex(16),
|
||||
api_key_sid: SecureRandom.hex(8),
|
||||
api_key_secret: SecureRandom.hex(16)
|
||||
api_key_secret: SecureRandom.hex(16),
|
||||
twiml_app_sid: "AP#{SecureRandom.hex(16)}"
|
||||
}
|
||||
end
|
||||
account
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
FactoryBot.define do
|
||||
factory :inbox_capacity_limit do
|
||||
association :agent_capacity_policy, factory: :agent_capacity_policy
|
||||
inbox
|
||||
conversation_limit { 5 }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channels::Twilio::TemplatesSyncJob do
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(twilio_channel) }.to have_enqueued_job(described_class)
|
||||
.on_queue('low')
|
||||
.with(twilio_channel)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
let(:template_sync_service) { instance_double(Twilio::TemplateSyncService) }
|
||||
|
||||
context 'with successful template sync' do
|
||||
it 'creates and calls the template sync service' do
|
||||
expect(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service)
|
||||
expect(template_sync_service).to receive(:call).and_return(true)
|
||||
|
||||
described_class.perform_now(twilio_channel)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with template sync exception' do
|
||||
let(:error_message) { 'Twilio API error' }
|
||||
|
||||
before do
|
||||
allow(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service)
|
||||
allow(template_sync_service).to receive(:call).and_raise(StandardError, error_message)
|
||||
end
|
||||
|
||||
it 'does not suppress the exception' do
|
||||
expect { described_class.perform_now(twilio_channel) }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil channel' do
|
||||
it 'handles nil channel gracefully' do
|
||||
expect { described_class.perform_now(nil) }.to raise_error(NoMethodError)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is configured to run on low priority queue' do
|
||||
expect(described_class.queue_name).to eq('low')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,598 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Twilio::TemplateProcessorService do
|
||||
subject(:processor_service) { described_class.new(channel: twilio_channel, template_params: template_params, message: message) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
let!(:contact) { create(:contact, account: account) }
|
||||
let!(:inbox) { create(:inbox, channel: twilio_channel, account: account) }
|
||||
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
|
||||
let!(:conversation) { create(:conversation, contact: contact, inbox: inbox, contact_inbox: contact_inbox) }
|
||||
let!(:message) { create(:message, conversation: conversation, account: account) }
|
||||
|
||||
let(:content_templates) do
|
||||
{
|
||||
'templates' => [
|
||||
{
|
||||
'content_sid' => 'HX123456789',
|
||||
'friendly_name' => 'hello_world',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Hello World!'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX987654321',
|
||||
'friendly_name' => 'greet',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => { '1' => 'John' },
|
||||
'category' => 'utility',
|
||||
'body' => 'Hello {{1}}!'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX555666777',
|
||||
'friendly_name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'media',
|
||||
'media_type' => 'image',
|
||||
'variables' => { '1' => 'https://example.com/image.jpg', '2' => 'iPhone', '3' => '$999' },
|
||||
'category' => 'marketing',
|
||||
'body' => 'Check out {{2}} for {{3}}'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX111222333',
|
||||
'friendly_name' => 'welcome_message',
|
||||
'language' => 'en_US',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'quick_reply',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Welcome! How can we help?'
|
||||
},
|
||||
{
|
||||
'content_sid' => 'HX444555666',
|
||||
'friendly_name' => 'order_status',
|
||||
'language' => 'es',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => { '1' => 'Juan', '2' => 'ORD123' },
|
||||
'category' => 'utility',
|
||||
'body' => 'Hola {{1}}, tu pedido {{2}} está confirmado'
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
twilio_channel.update!(content_templates: content_templates)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with blank template_params' do
|
||||
let(:template_params) { nil }
|
||||
|
||||
it 'returns nil values' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty template_params' do
|
||||
let(:template_params) { {} }
|
||||
|
||||
it 'returns nil values' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with template not found' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'nonexistent_template',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns nil values' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text templates' do
|
||||
context 'with simple text template (no variables)' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns content_sid and empty variables' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX123456789')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text template using processed_params format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'greet',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'1' => 'Alice',
|
||||
'2' => 'Premium User'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes key-value parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX987654321')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'Alice',
|
||||
'2' => 'Premium User'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with text template using WhatsApp Cloud API format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'greet',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Bob' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes WhatsApp format parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX987654321')
|
||||
expect(content_variables).to eq({ '1' => 'Bob' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple body parameters' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'greet',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Charlie' },
|
||||
{ 'type' => 'text', 'text' => 'VIP Member' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes multiple parameters with sequential indexing' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX987654321')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'Charlie',
|
||||
'2' => 'VIP Member'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with quick reply templates' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'welcome_message',
|
||||
'language' => 'en_US'
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes quick reply templates like text templates' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX111222333')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
|
||||
context 'with quick reply template having body parameters' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'welcome_message',
|
||||
'language' => 'en_US',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Diana' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes body parameters for quick reply templates' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX111222333')
|
||||
expect(content_variables).to eq({ '1' => 'Diana' })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with media templates' do
|
||||
context 'with media template using processed_params format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'1' => 'https://cdn.example.com/product.jpg',
|
||||
'2' => 'MacBook Pro',
|
||||
'3' => '$2499'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes key-value parameters for media templates' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://cdn.example.com/product.jpg',
|
||||
'2' => 'MacBook Pro',
|
||||
'3' => '$2499'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with media template using WhatsApp Cloud API format' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'image',
|
||||
'image' => { 'link' => 'https://example.com/product-image.jpg' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Samsung Galaxy' },
|
||||
{ 'type' => 'text', 'text' => '$899' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes media header and body parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/product-image.jpg',
|
||||
'2' => 'Samsung Galaxy',
|
||||
'3' => '$899'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with video media template' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'video',
|
||||
'video' => { 'link' => 'https://example.com/demo.mp4' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Product Demo' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes video media parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/demo.mp4',
|
||||
'2' => 'Product Demo'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with document media template' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'document',
|
||||
'document' => { 'link' => 'https://example.com/brochure.pdf' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Product Brochure' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes document media parameters correctly' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/brochure.pdf',
|
||||
'2' => 'Product Brochure'
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with header parameter without media link' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Header Text' }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'Body Text' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'skips header without media and processes body parameters' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({ '1' => 'Body Text' })
|
||||
end
|
||||
end
|
||||
|
||||
context 'with mixed component types' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'header',
|
||||
'parameters' => [
|
||||
{
|
||||
'type' => 'image',
|
||||
'image' => { 'link' => 'https://example.com/header.jpg' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'body',
|
||||
'parameters' => [
|
||||
{ 'type' => 'text', 'text' => 'First param' },
|
||||
{ 'type' => 'text', 'text' => 'Second param' }
|
||||
]
|
||||
},
|
||||
{
|
||||
'type' => 'footer',
|
||||
'parameters' => []
|
||||
}
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes supported components and ignores unsupported ones' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX555666777')
|
||||
expect(content_variables).to eq({
|
||||
'1' => 'https://example.com/header.jpg',
|
||||
'2' => 'First param',
|
||||
'3' => 'Second param'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with language matching' do
|
||||
context 'with exact language match' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'order_status',
|
||||
'language' => 'es'
|
||||
}
|
||||
end
|
||||
|
||||
it 'finds template with exact language match' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX444555666')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
|
||||
context 'with default language fallback' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world'
|
||||
# No language specified, should default to 'en'
|
||||
}
|
||||
end
|
||||
|
||||
it 'defaults to English when no language specified' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX123456789')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unapproved template status' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'unapproved_template',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
unapproved_template = {
|
||||
'content_sid' => 'HX_UNAPPROVED',
|
||||
'friendly_name' => 'unapproved_template',
|
||||
'language' => 'en',
|
||||
'status' => 'pending',
|
||||
'template_type' => 'text',
|
||||
'variables' => {},
|
||||
'body' => 'This is unapproved'
|
||||
}
|
||||
|
||||
updated_templates = content_templates['templates'] + [unapproved_template]
|
||||
twilio_channel.update!(
|
||||
content_templates: { 'templates' => updated_templates }
|
||||
)
|
||||
end
|
||||
|
||||
it 'ignores templates that are not approved' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to be_nil
|
||||
expect(content_variables).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with unknown template type' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'unknown_type',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
unknown_template = {
|
||||
'content_sid' => 'HX_UNKNOWN',
|
||||
'friendly_name' => 'unknown_type',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'catalog',
|
||||
'variables' => {},
|
||||
'body' => 'Catalog template'
|
||||
}
|
||||
|
||||
updated_templates = content_templates['templates'] + [unknown_template]
|
||||
twilio_channel.update!(
|
||||
content_templates: { 'templates' => updated_templates }
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns empty content variables for unknown template types' do
|
||||
content_sid, content_variables = processor_service.call
|
||||
|
||||
expect(content_sid).to eq('HX_UNKNOWN')
|
||||
expect(content_variables).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'template finding behavior' do
|
||||
context 'with no content_templates' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
twilio_channel.update!(content_templates: {})
|
||||
end
|
||||
|
||||
it 'returns nil values when content_templates is empty' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with nil content_templates' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'hello_world',
|
||||
'language' => 'en'
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
twilio_channel.update!(content_templates: nil)
|
||||
end
|
||||
|
||||
it 'returns nil values when content_templates is nil' do
|
||||
result = processor_service.call
|
||||
|
||||
expect(result).to eq([nil, nil])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,319 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Twilio::TemplateSyncService do
|
||||
subject(:sync_service) { described_class.new(channel: twilio_channel) }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
|
||||
|
||||
let(:twilio_client) { instance_double(Twilio::REST::Client) }
|
||||
let(:content_api) { double }
|
||||
let(:contents_list) { double }
|
||||
|
||||
# Mock Twilio template objects
|
||||
let(:text_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX123456789',
|
||||
friendly_name: 'hello_world',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Hello World!' } }
|
||||
)
|
||||
end
|
||||
|
||||
let(:media_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX987654321',
|
||||
friendly_name: 'product_showcase',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: { '1' => 'iPhone', '2' => '$999' },
|
||||
types: {
|
||||
'twilio/media' => {
|
||||
'body' => 'Check out {{1}} for {{2}}',
|
||||
'media' => ['https://example.com/image.jpg']
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:quick_reply_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX555666777',
|
||||
friendly_name: 'welcome_message',
|
||||
language: 'en_US',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/quick-reply' => {
|
||||
'body' => 'Welcome! How can we help?',
|
||||
'actions' => [
|
||||
{ 'id' => 'support', 'title' => 'Support' },
|
||||
{ 'id' => 'sales', 'title' => 'Sales' }
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:catalog_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX111222333',
|
||||
friendly_name: 'product_catalog',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/catalog' => {
|
||||
'body' => 'Check our catalog',
|
||||
'catalog_id' => 'catalog123'
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
|
||||
|
||||
before do
|
||||
allow(twilio_channel).to receive(:send).and_call_original
|
||||
allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client)
|
||||
allow(twilio_client).to receive(:content).and_return(content_api)
|
||||
allow(content_api).to receive(:v1).and_return(content_api)
|
||||
allow(content_api).to receive(:contents).and_return(contents_list)
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates)
|
||||
end
|
||||
|
||||
describe '#call' do
|
||||
context 'with successful sync' do
|
||||
it 'fetches templates from Twilio and updates the channel' do
|
||||
freeze_time do
|
||||
result = sync_service.call
|
||||
|
||||
expect(result).to be_truthy
|
||||
expect(contents_list).to have_received(:list).with(limit: 1000)
|
||||
|
||||
twilio_channel.reload
|
||||
expect(twilio_channel.content_templates).to be_present
|
||||
expect(twilio_channel.content_templates['templates']).to be_an(Array)
|
||||
expect(twilio_channel.content_templates['templates'].size).to eq(4)
|
||||
expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
it 'correctly formats text templates' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
text_template_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'hello_world'
|
||||
end
|
||||
|
||||
expect(text_template_data).to include(
|
||||
'content_sid' => 'HX123456789',
|
||||
'friendly_name' => 'hello_world',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'text',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Hello World!'
|
||||
)
|
||||
end
|
||||
|
||||
it 'correctly formats media templates' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
media_template_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'product_showcase'
|
||||
end
|
||||
|
||||
expect(media_template_data).to include(
|
||||
'content_sid' => 'HX987654321',
|
||||
'friendly_name' => 'product_showcase',
|
||||
'language' => 'en',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'media',
|
||||
'media_type' => nil, # Would be derived from media content if present
|
||||
'variables' => { '1' => 'iPhone', '2' => '$999' },
|
||||
'category' => 'utility',
|
||||
'body' => 'Check out {{1}} for {{2}}'
|
||||
)
|
||||
end
|
||||
|
||||
it 'correctly formats quick reply templates' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
quick_reply_template_data = twilio_channel.content_templates['templates'].find do |t|
|
||||
t['friendly_name'] == 'welcome_message'
|
||||
end
|
||||
|
||||
expect(quick_reply_template_data).to include(
|
||||
'content_sid' => 'HX555666777',
|
||||
'friendly_name' => 'welcome_message',
|
||||
'language' => 'en_US',
|
||||
'status' => 'approved',
|
||||
'template_type' => 'quick_reply',
|
||||
'media_type' => nil,
|
||||
'variables' => {},
|
||||
'category' => 'utility',
|
||||
'body' => 'Welcome! How can we help?'
|
||||
)
|
||||
end
|
||||
|
||||
it 'categorizes marketing templates correctly' do
|
||||
marketing_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_MARKETING',
|
||||
friendly_name: 'promo_offer_50_off',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => '50% off sale!' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
marketing_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
expect(marketing_data['category']).to eq('marketing')
|
||||
end
|
||||
|
||||
it 'categorizes authentication templates correctly' do
|
||||
auth_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_AUTH',
|
||||
friendly_name: 'otp_verification',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
auth_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
expect(auth_data['category']).to eq('authentication')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API error' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'handles Twilio::REST::TwilioError gracefully' do
|
||||
result = sync_service.call
|
||||
|
||||
expect(result).to be_falsey
|
||||
expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with generic error' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed')
|
||||
allow(Rails.logger).to receive(:error)
|
||||
end
|
||||
|
||||
it 'propagates non-Twilio errors' do
|
||||
expect { sync_service.call }.to raise_error(StandardError, 'Connection failed')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty templates list' do
|
||||
before do
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([])
|
||||
end
|
||||
|
||||
it 'updates channel with empty templates array' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
expect(twilio_channel.content_templates['templates']).to eq([])
|
||||
expect(twilio_channel.content_templates_last_updated).to be_present
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'template categorization behavior' do
|
||||
it 'defaults to utility category for unrecognized patterns' do
|
||||
generic_template = instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_GENERIC',
|
||||
friendly_name: 'order_status',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: { 'twilio/text' => { 'body' => 'Order updated' } }
|
||||
)
|
||||
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template])
|
||||
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
template_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
expect(template_data['category']).to eq('utility')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'template type detection' do
|
||||
context 'with multiple type definitions' do
|
||||
let(:mixed_template) do
|
||||
instance_double(
|
||||
Twilio::REST::Content::V1::ContentInstance,
|
||||
sid: 'HX_MIXED',
|
||||
friendly_name: 'mixed_type',
|
||||
language: 'en',
|
||||
date_created: Time.current,
|
||||
date_updated: Time.current,
|
||||
variables: {},
|
||||
types: {
|
||||
'twilio/media' => { 'body' => 'Media content' },
|
||||
'twilio/text' => { 'body' => 'Text content' }
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
|
||||
end
|
||||
|
||||
it 'prioritizes media type for type detection but text for body extraction' do
|
||||
sync_service.call
|
||||
|
||||
twilio_channel.reload
|
||||
template_data = twilio_channel.content_templates['templates'].first
|
||||
|
||||
# derive_template_type prioritizes media
|
||||
expect(template_data['template_type']).to eq('media')
|
||||
# but extract_body_content prioritizes text
|
||||
expect(template_data['body']).to eq('Text content')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user