Merge branch 'develop' into add-remove-agent-action-macros-automations

This commit is contained in:
Muhsin Keloth
2025-08-13 21:26:02 +05:30
committed by GitHub
50 changed files with 3127 additions and 765 deletions
+18 -5
View File
@@ -18,12 +18,25 @@ module ReportingEventHelper
end
def last_non_human_activity(conversation)
# check if a handoff event already exists
handoff_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_handoff').last
# Try to get either a handoff or reopened event first
# These will always take precedence over any other activity
# Also, any of these events can happen at any time in the course of a conversation lifecycle.
# So we pick the latest event
event = ReportingEvent.where(
conversation_id: conversation.id,
name: %w[conversation_bot_handoff conversation_opened]
).order(event_end_time: :desc).first
# if a handoff exists, last non human activity is when the handoff ended,
# otherwise it's when the conversation was created
handoff_event&.event_end_time || conversation.created_at
return event.event_end_time if event&.event_end_time
# Fallback to bot resolved event
# Because this will be closest to the most accurate activity instead of conversation.created_at
bot_event = ReportingEvent.where(conversation_id: conversation.id, name: 'conversation_bot_resolved').last
return bot_event.event_end_time if bot_event&.event_end_time
# If no events found, return conversation creation time
conversation.created_at
end
private
@@ -9,6 +9,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const emit = defineEmits(['submit', 'cancel']);
@@ -18,7 +19,9 @@ const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
};
const initialState = {
@@ -30,7 +33,7 @@ const initialState = {
};
const state = reactive({ ...initialState });
const processedParams = ref({});
const templateParserRef = ref(null);
const rules = {
title: { required, minLength: minLength(1) },
@@ -67,7 +70,7 @@ const inboxOptions = computed(() =>
const templateOptions = computed(() => {
if (!state.inboxId) return [];
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
return templates.map(template => {
// Create a more user-friendly label from template name
const friendlyName = template.name
@@ -88,26 +91,6 @@ const selectedTemplate = computed(() => {
?.template;
});
const templateString = computed(() => {
if (!selectedTemplate.value) return '';
try {
return (
selectedTemplate.value.components?.find(
component => component.type === 'BODY'
)?.text || ''
);
} catch (error) {
return '';
}
});
const processedString = computed(() => {
if (!templateString.value) return '';
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
return processedParams.value[variable] || `{{${variable}}}`;
});
});
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
@@ -122,8 +105,7 @@ const formErrors = computed(() => ({
}));
const hasRequiredTemplateParams = computed(() => {
const params = Object.values(processedParams.value);
return params.length === 0 || params.every(param => param.trim() !== '');
return templateParserRef.value?.v$?.$invalid === false || true;
});
const isSubmitDisabled = computed(
@@ -135,32 +117,18 @@ const formatToUTCString = localDateTime =>
const resetState = () => {
Object.assign(state, initialState);
processedParams.value = {};
v$.value.$reset();
};
const handleCancel = () => emit('cancel');
const generateVariables = () => {
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
if (!matchedVariables) {
processedParams.value = {};
return;
}
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
processedParams.value = finalVars.reduce((acc, variable) => {
acc[variable] = processedParams.value[variable] || '';
return acc;
}, {});
};
const prepareCampaignDetails = () => {
// Find the selected template to get its content
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
// Extract template content - this should be the template message body
const templateContent = templateString.value;
const templateContent = parserData?.renderedTemplate || '';
// Prepare template_params object with the same structure as used in contacts
const templateParams = {
@@ -168,7 +136,7 @@ const prepareCampaignDetails = () => {
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: processedParams.value,
processed_params: parserData?.processedParams || {},
};
return {
@@ -198,15 +166,6 @@ watch(
() => state.inboxId,
() => {
state.templateId = null;
processedParams.value = {};
}
);
// Generate variables when template changes
watch(
() => state.templateId,
() => {
generateVariables();
}
);
</script>
@@ -254,62 +213,12 @@ watch(
</p>
</div>
<!-- Template Preview -->
<div
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="selectedTemplate"
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
>
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ selectedTemplate.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
{{ selectedTemplate.language || 'en' }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md bg-n-alpha-black3">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ processedString }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
{{ selectedTemplate.category || 'UTILITY' }}
</div>
</div>
<!-- Template Variables -->
<div
v-if="Object.keys(processedParams).length > 0"
class="flex flex-col gap-3"
>
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
</label>
<div class="flex flex-col gap-2">
<div
v-for="(value, key) in processedParams"
:key="key"
class="flex gap-2 items-center"
>
<Input
v-model="processedParams[key]"
type="text"
class="flex-1"
:placeholder="
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
</div>
ref="templateParserRef"
:template="selectedTemplate"
/>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
@@ -2,6 +2,7 @@
import { ref, computed, onMounted, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useWindowSize } from '@vueuse/core';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { vOnClickOutside } from '@vueuse/components';
import { useAlert } from 'dashboard/composables';
@@ -15,6 +16,7 @@ import {
processContactableInboxes,
mergeInboxDetails,
} from 'dashboard/components-next/NewConversation/helpers/composeConversationHelper';
import wootConstants from 'dashboard/constants/globals';
import ComposeNewConversationForm from 'dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue';
@@ -37,9 +39,16 @@ const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const { width: windowWidth } = useWindowSize();
const { fetchSignatureFlagFromUISettings } = useUISettings();
const isSmallScreen = computed(
() => windowWidth.value < wootConstants.SMALL_SCREEN_BREAKPOINT
);
const viewInModal = computed(() => props.isModal || isSmallScreen.value);
const contacts = ref([]);
const selectedContact = ref(null);
const targetInbox = ref(null);
@@ -67,7 +76,7 @@ const directUploadsEnabled = computed(
const activeContact = computed(() => contactById.value(props.contactId));
const composePopoverClass = computed(() => {
if (props.isModal) return '';
if (viewInModal.value) return '';
return props.alignPosition === 'right'
? 'absolute ltr:left-0 ltr:right-[unset] rtl:right-0 rtl:left-[unset]'
@@ -179,14 +188,18 @@ const toggle = () => {
watch(
activeContact,
() => {
if (activeContact.value && props.contactId) {
const contactInboxes = activeContact.value?.contactInboxes || [];
(currentContact, previousContact) => {
if (currentContact && props.contactId) {
// Reset on contact change
if (currentContact?.id !== previousContact?.id) clearSelectedContact();
// First process the contactable inboxes to get the right structure
const processedInboxes = processContactableInboxes(contactInboxes);
const processedInboxes = processContactableInboxes(
currentContact.contactInboxes || []
);
// Then Merge processedInboxes with the inboxes list
selectedContact.value = {
...activeContact.value,
...currentContact,
contactInboxes: mergeInboxDetails(processedInboxes, inboxesList.value),
};
}
@@ -202,7 +215,7 @@ const handleClickOutside = () => {
};
const onModalBackdropClick = () => {
if (!props.isModal) return;
if (!viewInModal.value) return;
handleClickOutside();
};
@@ -231,7 +244,7 @@ useKeyboardEvents(keyboardEvents);
]"
class="relative"
:class="{
'z-40': showComposeNewConversation,
'z-50': showComposeNewConversation && !viewInModal,
}"
>
<slot
@@ -243,12 +256,12 @@ useKeyboardEvents(keyboardEvents);
v-if="showComposeNewConversation"
:class="{
'fixed z-50 bg-n-alpha-black1 backdrop-blur-[4px] flex items-start pt-[clamp(3rem,15vh,12rem)] justify-center inset-0':
isModal,
viewInModal,
}"
@click.self="onModalBackdropClick"
>
<ComposeNewConversationForm
:class="[{ 'mt-2': !isModal }, composePopoverClass]"
:class="[{ 'mt-2': !viewInModal }, composePopoverClass]"
:contacts="contacts"
:contact-id="contactId"
:is-loading="isSearching"
@@ -25,6 +25,7 @@ const props = defineProps({
hasNoInbox: { type: Boolean, default: false },
isDropdownActive: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
inboxId: { type: Number, default: null },
});
const emit = defineEmits([
@@ -150,9 +151,10 @@ useKeyboardEvents(keyboardEvents);
<div
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<WhatsAppOptions
v-if="isWhatsappInbox"
:inbox-id="inboxId"
:message-templates="messageTemplates"
@send-message="emit('sendWhatsappMessage', $event)"
/>
@@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
/>
</div>
<div class="flex items-center gap-2">
<div class="flex gap-2 items-center">
<Button
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
variant="faded"
@@ -265,7 +265,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<template>
<div
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl"
class="w-[42rem] divide-y divide-n-strong overflow-visible transition-all duration-300 ease-in-out top-full justify-between flex flex-col bg-n-alpha-3 border border-n-strong shadow-sm backdrop-blur-[100px] rounded-xl min-w-0"
>
<ContactSelector
:contacts="contacts"
@@ -336,6 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
:is-loading="isCreating"
:disable-send-button="isCreating"
:has-selected-inbox="!!targetInbox"
:inbox-id="targetInbox?.id"
:has-no-inbox="showNoInboxAlert"
:is-dropdown-active="isAnyDropdownActive"
:message-signature="messageSignature"
@@ -1,24 +1,25 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
import WhatsappTemplate from './WhatsappTemplate.vue';
const props = defineProps({
messageTemplates: {
type: Array,
default: () => [],
inboxId: {
type: Number,
required: true,
},
});
const emit = defineEmits(['sendMessage']);
const { t } = useI18n();
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
const getFilteredWhatsAppTemplates = useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
);
const searchQuery = ref('');
const selectedTemplate = ref(null);
@@ -26,19 +27,7 @@ const selectedTemplate = ref(null);
const showTemplatesMenu = ref(false);
const whatsAppTemplateMessages = computed(() => {
// Add null check and ensure it's an array
const templates = Array.isArray(props.messageTemplates)
? props.messageTemplates
: [];
// TODO: Remove the last filter when we support all formats
return templates
.filter(template => template?.status?.toLowerCase() === 'approved')
.filter(template => {
return template?.components?.every(component => {
return !formatsToRemove.includes(component.format);
});
});
return getFilteredWhatsAppTemplates.value(props.inboxId);
});
const filteredTemplates = computed(() => {
@@ -106,7 +95,7 @@ const handleSendMessage = template => {
<div
v-for="template in filteredTemplates"
:key="template.id"
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
@click="handleTemplateClick(template)"
>
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
@@ -115,12 +104,12 @@ const handleSendMessage = template => {
</p>
</div>
<template v-if="filteredTemplates.length === 0">
<p class="w-full pt-2 text-sm text-n-slate-11">
<p class="pt-2 w-full text-sm text-n-slate-11">
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
</p>
</template>
</div>
<WhatsappTemplateParser
<WhatsappTemplate
v-if="selectedTemplate"
:template="selectedTemplate"
@send-message="handleSendMessage"
@@ -0,0 +1,64 @@
<script setup>
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
defineProps({
template: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['sendMessage', 'back']);
const { t } = useI18n();
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const handleBack = () => {
emit('back');
};
</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"
>
<div class="w-full">
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@back="handleBack"
>
<template #actions="{ sendMessage, goBack, disabled }">
<div class="flex gap-3 justify-between items-end w-full h-14">
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
)
"
color="slate"
variant="faded"
class="w-full font-medium"
@click="goBack"
/>
<Button
:label="
t(
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
)
"
class="w-full font-medium"
:disabled="disabled"
@click="sendMessage"
/>
</div>
</template>
</WhatsAppTemplateParser>
</div>
</div>
</template>
@@ -509,10 +509,8 @@ const menuItems = computed(() => {
class="bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak flex flex-col text-sm pb-1 fixed top-0 ltr:left-0 rtl:right-0 h-full z-40 transition-transform duration-200 ease-in-out md:static w-[200px] basis-[200px] md:flex-shrink-0 md:ltr:translate-x-0 md:rtl:-translate-x-0"
:class="[
{
'ltr:translate-x-0 rtl:-translate-x-0 shadow-lg md:shadow-none':
isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full md:translate-x-0':
!isMobileSidebarOpen,
'shadow-lg md:shadow-none': isMobileSidebarOpen,
'ltr:-translate-x-full rtl:translate-x-full': !isMobileSidebarOpen,
},
]"
>
@@ -0,0 +1,279 @@
<script setup>
import { ref, computed, onMounted, watch } 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 {
buildTemplateParameters,
allKeysRequired,
replaceTemplateVariables,
DEFAULT_LANGUAGE,
DEFAULT_CATEGORY,
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
const props = defineProps({
template: {
type: Object,
default: () => ({}),
validator: value => {
if (!value || typeof value !== 'object') return false;
if (!value.components || !Array.isArray(value.components)) return false;
return true;
},
},
});
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
const { t } = useI18n();
const processedParams = ref({});
const languageLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
});
const categoryLabel = computed(() => {
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || DEFAULT_CATEGORY}`;
});
const headerComponent = computed(() => {
return findComponentByType(props.template, COMPONENT_TYPES.HEADER);
});
const bodyComponent = computed(() => {
return findComponentByType(props.template, COMPONENT_TYPES.BODY);
});
const bodyText = computed(() => {
return bodyComponent.value?.text || '';
});
const hasMediaHeader = computed(() =>
MEDIA_FORMATS.includes(headerComponent.value?.format)
);
const formatType = computed(() => {
const format = headerComponent.value?.format;
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
});
const hasVariables = computed(() => {
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
});
const renderedTemplate = computed(() => {
return replaceTemplateVariables(bodyText.value, processedParams.value);
});
const isFormInvalid = computed(() => {
if (!hasVariables.value && !hasMediaHeader.value) return false;
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
return true;
}
if (hasVariables.value && processedParams.value.body) {
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
value => !value
);
if (hasEmptyBodyVariable) return true;
}
if (processedParams.value.buttons) {
const hasEmptyButtonParameter = processedParams.value.buttons.some(
button => !button.parameter
);
if (hasEmptyButtonParameter) return true;
}
return false;
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(hasVariables),
allKeysRequired,
},
},
{ processedParams }
);
const initializeTemplateParameters = () => {
processedParams.value = buildTemplateParameters(
props.template,
hasMediaHeader.value
);
};
const updateMediaUrl = value => {
processedParams.value.header ??= {};
processedParams.value.header.media_url = value;
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const { name, category, language, namespace } = props.template;
const payload = {
message: renderedTemplate.value,
templateParams: {
name,
category,
language,
namespace,
processed_params: processedParams.value,
},
};
emit('sendMessage', payload);
};
const resetTemplate = () => {
emit('resetTemplate');
};
const goBack = () => {
emit('back');
};
onMounted(initializeTemplateParameters);
watch(
() => props.template,
() => {
initializeTemplateParameters();
v$.value.$reset();
},
{ deep: true }
);
defineExpose({
processedParams,
hasVariables,
hasMediaHeader,
headerComponent,
renderedTemplate,
v$,
updateMediaUrl,
sendMessage,
resetTemplate,
goBack,
});
</script>
<template>
<div>
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
<div class="flex justify-between items-center">
<h3 class="text-sm font-medium text-n-slate-12">
{{ template.name }}
</h3>
<span class="text-xs text-n-slate-11">
{{ languageLabel }}
</span>
</div>
<div class="flex flex-col gap-2">
<div class="rounded-md">
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
{{ renderedTemplate }}
</div>
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ categoryLabel }}
</div>
</div>
<div v-if="hasVariables || hasMediaHeader">
<div v-if="hasMediaHeader" class="mb-4">
<p class="mb-2.5 text-sm font-semibold">
{{
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
type: formatType,
}) || `${formatType} Header`
}}
</p>
<div class="flex items-center mb-2.5">
<Input
:model-value="processedParams.header?.media_url || ''"
type="url"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
type: formatType,
})
"
@update:model-value="updateMediaUrl"
/>
</div>
</div>
<!-- Body Variables Section -->
<div v-if="processedParams.body">
<p class="mb-2.5 text-sm font-semibold">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams.body"
:key="`body-${key}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.body[key]"
type="text"
class="flex-1"
:placeholder="
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
variable: key,
})
"
/>
</div>
</div>
<!-- Button Variables Section -->
<div v-if="processedParams.buttons">
<p class="mb-2.5 text-sm font-semibold">
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
</p>
<div
v-for="(button, index) in processedParams.buttons"
:key="`button-${index}`"
class="flex items-center mb-2.5"
>
<Input
v-model="processedParams.buttons[index].parameter"
type="text"
class="flex-1"
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
/>
</div>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<slot
name="actions"
:send-message="sendMessage"
:reset-template="resetTemplate"
:go-back="goBack"
:is-valid="!v$.$invalid"
:disabled="isFormInvalid"
/>
</div>
</template>
@@ -1,4 +1,4 @@
<script>
<script setup>
/**
* This component handles parsing and sending WhatsApp message templates.
* It works as follows:
@@ -8,158 +8,51 @@
* 4. Replaces placeholders with user-provided values.
* 5. Emits events to send the processed message or reset the template.
*/
import { ref, computed, onMounted } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { requiredIf } from '@vuelidate/validators';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: {
NextButton,
defineProps({
template: {
type: Object,
default: () => ({}),
},
props: {
template: {
type: Object,
default: () => ({}),
},
},
emits: ['sendMessage', 'resetTemplate'],
setup(props, { emit }) {
const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
});
const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
const emit = defineEmits(['sendMessage', 'resetTemplate']);
const processedParams = ref({});
const handleSendMessage = payload => {
emit('sendMessage', payload);
};
const templateString = computed(() => {
return props.template.components.find(
component => component.type === 'BODY'
).text;
});
const variables = computed(() => {
return templateString.value.match(/{{([^}]+)}}/g);
});
const processedString = computed(() => {
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.value[variableKey] || `{{${variable}}}`;
});
});
const v$ = useVuelidate(
{
processedParams: {
requiredIfKeysPresent: requiredIf(variables),
allKeysRequired,
},
},
{ processedParams }
);
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 resetTemplate = () => {
emit('resetTemplate');
};
const sendMessage = () => {
v$.value.$touch();
if (v$.value.$invalid) 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);
return {
processedParams,
variables,
templateString,
processedString,
v$,
resetTemplate,
sendMessage,
};
},
const handleResetTemplate = () => {
emit('resetTemplate');
};
</script>
<template>
<div class="w-full">
<textarea
v-model="processedString"
rows="4"
readonly
class="template-input"
/>
<div v-if="variables" class="p-2.5">
<p class="text-sm font-semibold mb-2.5">
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
</p>
<div
v-for="(variable, key) in processedParams"
:key="key"
class="items-center flex mb-2.5"
>
<span
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
>
{{ key }}
</span>
<woot-input
v-model="processedParams[key]"
type="text"
class="flex-1 text-sm ml-2.5"
:styles="{ marginBottom: 0 }"
/>
</div>
<p
v-if="v$.$dirty && v$.$invalid"
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
>
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
</p>
</div>
<footer class="flex justify-end gap-2">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
@click="sendMessage"
/>
</footer>
<WhatsAppTemplateParser
:template="template"
@send-message="handleSendMessage"
@reset-template="handleResetTemplate"
>
<template #actions="{ sendMessage, resetTemplate, disabled }">
<footer class="flex gap-2 justify-end">
<NextButton
faded
slate
type="reset"
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
@click="resetTemplate"
/>
<NextButton
type="button"
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
:disabled="disabled"
@click="sendMessage"
/>
</footer>
</template>
</WhatsAppTemplateParser>
</div>
</template>
@@ -1,60 +1,71 @@
<script>
<script setup>
import { ref, computed, toRef } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
import {
COMPONENT_TYPES,
MEDIA_FORMATS,
findComponentByType,
} from 'dashboard/helper/templateHelper';
import Icon from 'dashboard/components-next/icon/Icon.vue';
// TODO: Remove this when we support all formats
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
import { useI18n } from 'vue-i18n';
export default {
components: {
Icon,
},
props: {
inboxId: {
type: Number,
default: undefined,
},
},
emits: ['onSelect'],
data() {
return {
query: '',
isRefreshing: false,
};
},
computed: {
whatsAppTemplateMessages() {
// TODO: Remove the last filter when we support all formats
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
.filter(template => template.status.toLowerCase() === 'approved')
.filter(template => {
return template.components.every(component => {
return !formatsToRemove.includes(component.format);
});
});
},
filteredTemplateMessages() {
return this.whatsAppTemplateMessages.filter(template =>
template.name.toLowerCase().includes(this.query.toLowerCase())
);
},
},
methods: {
getTemplatebody(template) {
return template.components.find(component => component.type === 'BODY')
.text;
},
async refreshTemplates() {
this.isRefreshing = true;
try {
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
this.isRefreshing = false;
}
},
const props = defineProps({
inboxId: {
type: Number,
default: undefined,
},
});
const emit = defineEmits(['onSelect']);
const { t } = useI18n();
const store = useStore();
const query = ref('');
const isRefreshing = ref(false);
const whatsAppTemplateMessages = useFunctionGetter(
'inboxes/getFilteredWhatsAppTemplates',
toRef(props, 'inboxId')
);
const filteredTemplateMessages = computed(() =>
whatsAppTemplateMessages.value.filter(template =>
template.name.toLowerCase().includes(query.value.toLowerCase())
)
);
const getTemplateBody = template => {
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
};
const getTemplateHeader = template => {
return findComponentByType(template, COMPONENT_TYPES.HEADER);
};
const getTemplateFooter = template => {
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
};
const getTemplateButtons = template => {
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
};
const hasMediaContent = template => {
const header = getTemplateHeader(template);
return header && MEDIA_FORMATS.includes(header.format);
};
const refreshTemplates = async () => {
isRefreshing.value = true;
try {
await store.dispatch('inboxes/syncTemplates', props.inboxId);
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
} catch (error) {
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
} finally {
isRefreshing.value = false;
}
};
</script>
@@ -68,14 +79,14 @@ export default {
<input
v-model="query"
type="search"
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
/>
</div>
<button
:disabled="isRefreshing"
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
@click="refreshTemplates"
>
<Icon
@@ -91,7 +102,7 @@ export default {
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
<button
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
@click="$emit('onSelect', template)"
@click="emit('onSelect', template)"
>
<div>
<div class="flex justify-between items-center mb-2.5">
@@ -101,21 +112,73 @@ export default {
<span
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
>
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
{{ template.language }}
</span>
</div>
<div>
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
<!-- Header -->
<div v-if="getTemplateHeader(template)" class="mb-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
</p>
<p class="label-body">{{ getTemplatebody(template) }}</p>
<div
v-if="getTemplateHeader(template).format === 'TEXT'"
class="text-sm label-body"
>
{{ getTemplateHeader(template).text }}
</div>
<div
v-else-if="hasMediaContent(template)"
class="text-sm italic text-n-slate-11"
>
{{
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
format: getTemplateHeader(template).format,
}) ||
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
}}
</div>
</div>
<div class="mt-5">
<p class="font-medium">
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
<!-- Body -->
<div>
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
</p>
<p>{{ template.category }}</p>
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
</div>
<!-- Footer -->
<div v-if="getTemplateFooter(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
</p>
<p class="text-sm label-body">
{{ getTemplateFooter(template).text }}
</p>
</div>
<!-- Buttons -->
<div v-if="getTemplateButtons(template)" class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="button in getTemplateButtons(template).buttons"
:key="button.text"
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
>
{{ button.text }}
</span>
</div>
</div>
<div class="mt-3">
<p class="text-xs font-medium text-n-slate-11">
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
</p>
<p class="text-sm">{{ template.category }}</p>
</div>
</div>
</button>
@@ -128,13 +191,13 @@ export default {
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
<div v-if="query && whatsAppTemplateMessages.length">
<p>
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
<strong>{{ query }}</strong>
</p>
</div>
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
<p class="text-n-slate-11">
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
</p>
</div>
</div>
@@ -196,6 +196,7 @@ describe('useAutomation', () => {
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
automationTypes.conversation_resolved = { conditions: [] };
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
@@ -8,7 +8,7 @@ export const DEFAULT_MESSAGE_CREATED_CONDITION = [
},
];
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
export const DEFAULT_CONVERSATION_CONDITION = [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
@@ -5,7 +5,7 @@ import {
} from 'dashboard/routes/dashboard/settings/automation/operators';
import {
DEFAULT_MESSAGE_CREATED_CONDITION,
DEFAULT_CONVERSATION_OPENED_CONDITION,
DEFAULT_CONVERSATION_CONDITION,
DEFAULT_OTHER_CONDITION,
DEFAULT_ACTIONS,
} from 'dashboard/constants/automation';
@@ -169,8 +169,11 @@ export const getDefaultConditions = eventName => {
if (eventName === 'message_created') {
return DEFAULT_MESSAGE_CREATED_CONDITION;
}
if (eventName === 'conversation_opened') {
return DEFAULT_CONVERSATION_OPENED_CONDITION;
if (
eventName === 'conversation_opened' ||
eventName === 'conversation_resolved'
) {
return DEFAULT_CONVERSATION_CONDITION;
}
return DEFAULT_OTHER_CONDITION;
};
@@ -0,0 +1,368 @@
import {
replaceTemplateVariables,
buildTemplateParameters,
processVariable,
allKeysRequired,
} from '../templateHelper';
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
describe('templateHelper', () => {
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
describe('processVariable', () => {
it('should remove curly braces from variables', () => {
expect(processVariable('{{name}}')).toBe('name');
expect(processVariable('{{1}}')).toBe('1');
expect(processVariable('{{customer_id}}')).toBe('customer_id');
});
});
describe('allKeysRequired', () => {
it('should return true when all keys have values', () => {
const obj = { name: 'John', age: '30' };
expect(allKeysRequired(obj)).toBe(true);
});
it('should return false when some keys are empty', () => {
const obj = { name: 'John', age: '' };
expect(allKeysRequired(obj)).toBe(false);
});
it('should return true for empty object', () => {
expect(allKeysRequired({})).toBe(true);
});
});
describe('replaceTemplateVariables', () => {
const templateText =
"Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.";
it('should replace all variables with provided values', () => {
const processedParams = {
body: {
1: 'John',
2: '123 Main St',
3: '2025-01-15',
4: '10:00 AM',
5: '2:00 PM',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
);
});
it('should keep original variable format when no replacement value provided', () => {
const processedParams = {
body: {
1: 'John',
3: '2025-01-15',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toContain('John');
expect(result).toContain('2025-01-15');
expect(result).toContain('{{2}}');
expect(result).toContain('{{4}}');
expect(result).toContain('{{5}}');
});
it('should handle empty processedParams', () => {
const result = replaceTemplateVariables(templateText, {});
expect(result).toBe(templateText);
});
});
describe('buildTemplateParameters', () => {
it('should build parameters for template with body variables', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.body).toEqual({
1: '',
2: '',
3: '',
4: '',
5: '',
});
});
it('should include header parameters when hasMediaHeader is true', () => {
const imageTemplate = templates.find(
t => t.name === 'order_confirmation'
);
const result = buildTemplateParameters(imageTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
});
it('should not include header parameters when hasMediaHeader is false', () => {
const result = buildTemplateParameters(technicianTemplate, false);
expect(result.header).toBeUndefined();
});
it('should handle template with no body component', () => {
const templateWithoutBody = {
components: [{ type: 'HEADER', format: 'TEXT' }],
};
const result = buildTemplateParameters(templateWithoutBody, false);
expect(result).toEqual({});
});
it('should handle template with no variables', () => {
const templateWithoutVars = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(templateWithoutVars, false);
expect(result.body).toBeUndefined();
});
it('should handle URL buttons with variables for non-authentication templates', () => {
const templateWithUrlButton = {
category: 'MARKETING',
components: [
{
type: 'BODY',
text: 'Check out our website at {{site_url}}',
},
{
type: 'BUTTONS',
buttons: [
{
type: 'URL',
url: 'https://example.com/{{campaign_id}}',
text: 'Visit Site',
},
],
},
],
};
const result = buildTemplateParameters(templateWithUrlButton, false);
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{campaign_id}}',
variables: ['campaign_id'],
},
]);
});
it('should handle templates with no variables', () => {
const emptyTemplate = templates.find(
t => t.name === 'no_variable_template'
);
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should build parameters for templates with multiple component types', () => {
const complexTemplate = {
components: [
{ type: 'HEADER', format: 'IMAGE' },
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
{ type: 'FOOTER', text: 'Thank you for your business' },
{
type: 'BUTTONS',
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
},
],
};
const result = buildTemplateParameters(complexTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'image',
});
expect(result.body).toEqual({ 1: '', 2: '' });
expect(result.buttons).toEqual([
{
type: 'url',
parameter: '',
url: 'https://example.com/{{3}}',
variables: ['3'],
},
]);
});
it('should handle copy code buttons correctly', () => {
const copyCodeTemplate = templates.find(
t => t.name === 'discount_coupon'
);
const result = buildTemplateParameters(copyCodeTemplate, false);
expect(result.body).toBeDefined();
expect(result.buttons).toEqual([
{
type: 'copy_code',
parameter: '',
},
]);
});
it('should handle templates with document headers', () => {
const documentTemplate = templates.find(
t => t.name === 'purchase_receipt'
);
const result = buildTemplateParameters(documentTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'document',
});
expect(result.body).toEqual({
1: '',
2: '',
3: '',
});
});
it('should handle video header templates', () => {
const videoTemplate = templates.find(t => t.name === 'training_video');
const result = buildTemplateParameters(videoTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
name: '',
date: '',
});
});
});
describe('enhanced format validation', () => {
it('should validate enhanced format structure', () => {
const processedParams = {
body: { 1: 'John', 2: 'Order123' },
header: {
media_url: 'https://example.com/image.jpg',
media_type: 'image',
},
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
};
// Test that structure is properly formed
expect(processedParams.body).toBeDefined();
expect(typeof processedParams.body).toBe('object');
expect(processedParams.header).toBeDefined();
expect(Array.isArray(processedParams.buttons)).toBe(true);
});
it('should handle empty component sections', () => {
const processedParams = {
body: {},
header: {},
buttons: [],
};
expect(allKeysRequired(processedParams.body)).toBe(true);
expect(allKeysRequired(processedParams.header)).toBe(true);
expect(processedParams.buttons.length).toBe(0);
});
it('should validate parameter completeness', () => {
const incompleteParams = {
body: { 1: 'John', 2: '' },
};
expect(allKeysRequired(incompleteParams.body)).toBe(false);
});
it('should handle edge cases in processVariable', () => {
expect(processVariable('{{')).toBe('');
expect(processVariable('}}')).toBe('');
expect(processVariable('')).toBe('');
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
});
it('should handle special characters in template variables', () => {
/* eslint-disable no-template-curly-in-string */
const templateText =
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
/* eslint-enable no-template-curly-in-string */
const processedParams = {
body: {
user_name: 'John & Jane',
order_id: '12345',
amount: '99.99',
},
};
const result = replaceTemplateVariables(templateText, processedParams);
expect(result).toBe(
'Welcome John & Jane, your order #12345 costs $99.99'
);
});
it('should handle templates with mixed parameter types', () => {
const mixedTemplate = {
components: [
{ type: 'HEADER', format: 'VIDEO' },
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
{ type: 'FOOTER', text: 'Thank you' },
{
type: 'BUTTONS',
buttons: [
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
{ type: 'COPY_CODE' },
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
],
},
],
};
const result = buildTemplateParameters(mixedTemplate, true);
expect(result.header).toEqual({
media_url: '',
media_type: 'video',
});
expect(result.body).toEqual({
order_id: '',
status: '',
});
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
expect(result.buttons[0].type).toBe('url');
expect(result.buttons[1].type).toBe('copy_code');
});
it('should handle templates with no processable components', () => {
const emptyTemplate = {
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
{ type: 'BODY', text: 'Static body with no variables' },
{ type: 'FOOTER', text: 'Static footer' },
],
};
const result = buildTemplateParameters(emptyTemplate, false);
expect(result).toEqual({});
});
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
const partialParams = {
body: {
name: 'John',
// order_id missing
status: 'ready',
},
};
const result = replaceTemplateVariables(templateText, partialParams);
expect(result).toBe('Hi John, order {{order_id}} is ready');
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
});
});
});
@@ -0,0 +1,91 @@
// Constants
export const DEFAULT_LANGUAGE = 'en';
export const DEFAULT_CATEGORY = 'UTILITY';
export const COMPONENT_TYPES = {
HEADER: 'HEADER',
BODY: 'BODY',
BUTTONS: 'BUTTONS',
};
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
export const findComponentByType = (template, type) =>
template.components?.find(component => component.type === type);
export const processVariable = str => {
return str.replace(/{{|}}/g, '');
};
export const allKeysRequired = value => {
const keys = Object.keys(value);
return keys.every(key => value[key]);
};
export const replaceTemplateVariables = (templateText, processedParams) => {
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
const variableKey = processVariable(variable);
return processedParams.body?.[variableKey] || `{{${variable}}}`;
});
};
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
const allVariables = {};
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
if (!bodyComponent) return allVariables;
const templateString = bodyComponent.text;
// Process body variables
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
if (matchedVariables) {
allVariables.body = {};
matchedVariables.forEach(variable => {
const key = processVariable(variable);
allVariables.body[key] = '';
});
}
if (hasMediaHeaderValue) {
if (!allVariables.header) allVariables.header = {};
allVariables.header.media_url = '';
allVariables.header.media_type = headerComponent.format.toLowerCase();
}
// Process button variables
const buttonComponents = template.components.filter(
component => component.type === COMPONENT_TYPES.BUTTONS
);
buttonComponents.forEach(buttonComponent => {
if (buttonComponent.buttons) {
buttonComponent.buttons.forEach((button, index) => {
// Handle URL buttons with variables
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
if (buttonVars.length > 0) {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'url',
parameter: '',
url: button.url,
variables: buttonVars.map(v => processVariable(v)),
};
}
}
// Handle copy code buttons
if (button.type === 'COPY_CODE') {
if (!allVariables.buttons) allVariables.buttons = [];
allVariables.buttons[index] = {
type: 'copy_code',
parameter: '',
};
}
});
}
});
return allVariables;
};
@@ -131,6 +131,7 @@
"CONVERSATION_CREATED": "Conversation Created",
"CONVERSATION_UPDATED": "Conversation Updated",
"MESSAGE_CREATED": "Message Created",
"CONVERSATION_RESOLVED": "Conversation Resolved",
"CONVERSATION_OPENED": "Conversation Opened"
},
"ACTIONS": {
@@ -1,29 +1,46 @@
{
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
}
"WHATSAPP_TEMPLATES": {
"MODAL": {
"TITLE": "Whatsapp Templates",
"SUBTITLE": "Select the whatsapp template you want to send",
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
},
"PICKER": {
"SEARCH_PLACEHOLDER": "Search Templates",
"NO_TEMPLATES_FOUND": "No templates found for",
"HEADER": "Header",
"BODY": "Body",
"FOOTER": "Footer",
"BUTTONS": "Buttons",
"CATEGORY": "Category",
"MEDIA_CONTENT": "Media Content",
"MEDIA_CONTENT_FALLBACK": "media content",
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
"REFRESH_BUTTON": "Refresh templates",
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
"LABELS": {
"LANGUAGE": "Language",
"TEMPLATE_BODY": "Template Body",
"CATEGORY": "Category"
}
},
"PARSER": {
"VARIABLES_LABEL": "Variables",
"LANGUAGE": "Language",
"CATEGORY": "Category",
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
"GO_BACK_LABEL": "Go Back",
"SEND_MESSAGE_LABEL": "Send Message",
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
"MEDIA_HEADER_LABEL": "{type} Header",
"OTP_CODE": "Enter 4-8 digit OTP",
"EXPIRY_MINUTES": "Enter expiry minutes",
"BUTTON_PARAMETERS": "Button Parameters",
"BUTTON_LABEL": "Button {index}",
"COUPON_CODE": "Enter coupon code (max 15 chars)",
"MEDIA_URL_LABEL": "Enter {type} URL",
"BUTTON_PARAMETER": "Enter button parameter"
}
}
}
@@ -468,6 +468,106 @@ export const AUTOMATIONS = {
},
],
},
conversation_resolved: {
conditions: [
{
key: 'browser_language',
name: 'BROWSER_LANGUAGE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'email',
name: 'EMAIL',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'mail_subject',
name: 'MAIL_SUBJECT',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'country_code',
name: 'COUNTRY_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'referer',
name: 'REFERER_LINK',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_2,
},
{
key: 'assignee_id',
name: 'ASSIGNEE_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'phone_number',
name: 'PHONE_NUMBER',
inputType: 'plain_text',
filterOperators: OPERATOR_TYPES_6,
},
{
key: 'team_id',
name: 'TEAM_NAME',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_3,
},
{
key: 'inbox_id',
name: 'INBOX',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'conversation_language',
name: 'CONVERSATION_LANGUAGE',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'priority',
name: 'PRIORITY',
inputType: 'multi_select',
filterOperators: OPERATOR_TYPES_1,
},
],
actions: [
{
key: 'assign_agent',
name: 'ASSIGN_AGENT',
},
{
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'send_email_to_team',
name: 'SEND_EMAIL_TO_TEAM',
},
{
key: 'send_message',
name: 'SEND_MESSAGE',
},
{
key: 'send_email_transcript',
name: 'SEND_EMAIL_TRANSCRIPT',
},
{
key: 'send_webhook_event',
name: 'SEND_WEBHOOK_EVENT',
},
{
key: 'send_attachment',
name: 'SEND_ATTACHMENT',
},
],
},
};
export const AUTOMATION_RULE_EVENTS = [
@@ -479,6 +579,10 @@ export const AUTOMATION_RULE_EVENTS = [
key: 'conversation_updated',
value: 'CONVERSATION_UPDATED',
},
{
key: 'conversation_resolved',
value: 'CONVERSATION_RESOLVED',
},
{
key: 'message_created',
value: 'MESSAGE_CREATED',
@@ -44,15 +44,52 @@ export const getters = {
const messagesTemplates =
whatsAppMessageTemplates || apiInboxMessageTemplates;
// filtering out the whatsapp templates with media
if (messagesTemplates instanceof Array) {
return messagesTemplates.filter(template => {
return !template.components.some(
i => i.format === 'IMAGE' || i.format === 'VIDEO'
);
});
return messagesTemplates;
},
getFilteredWhatsAppTemplates: $state => inboxId => {
const [inbox] = $state.records.filter(
record => record.id === Number(inboxId)
);
const {
message_templates: whatsAppMessageTemplates,
additional_attributes: additionalAttributes,
} = inbox || {};
const { message_templates: apiInboxMessageTemplates } =
additionalAttributes || {};
const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
if (!templates || !Array.isArray(templates)) {
return [];
}
return [];
return templates.filter(template => {
// Ensure template has required properties
if (!template || !template.status || !template.components) {
return false;
}
// Only show approved templates
if (template.status.toLowerCase() !== 'approved') {
return false;
}
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
const hasUnsupportedComponents = template.components.some(
component =>
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
component.type
) ||
(component.type === 'HEADER' && component.format === 'LOCATION')
);
if (hasUnsupportedComponents) {
return false;
}
return true;
});
},
getNewConversationInboxes($state) {
return $state.records.filter(inbox => {
@@ -1,5 +1,6 @@
import { getters } from '../../inboxes';
import inboxList from './fixtures';
import { templates } from './templateFixtures';
describe('#getters', () => {
it('getInboxes', () => {
@@ -93,4 +94,269 @@ describe('#getters', () => {
provider: 'default',
});
});
describe('getFilteredWhatsAppTemplates', () => {
it('returns empty array when inbox not found', () => {
const state = { records: [] };
expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
});
it('returns empty array when templates is null or undefined', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: null,
additional_attributes: { message_templates: undefined },
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('returns empty array when templates is not an array', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: 'invalid',
additional_attributes: {},
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('filters out templates without required properties', () => {
const invalidTemplates = [
{ name: 'incomplete_template' }, // missing status and components
{ status: 'approved' }, // missing name and components
{ name: 'another_incomplete', status: 'approved' }, // missing components
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: invalidTemplates,
},
],
};
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
});
it('filters out non-approved templates', () => {
const mixedStatusTemplates = [
{
name: 'pending_template',
status: 'pending',
components: [{ type: 'BODY', text: 'Test' }],
},
{
name: 'rejected_template',
status: 'rejected',
components: [{ type: 'BODY', text: 'Test' }],
},
{
name: 'approved_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Test' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: mixedStatusTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('approved_template');
});
it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
const interactiveTemplates = [
{
name: 'list_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Choose an option' },
{ type: 'LIST', sections: [] },
],
},
{
name: 'product_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Product info' },
{ type: 'PRODUCT', catalog_id: '123' },
],
},
{
name: 'catalog_template',
status: 'approved',
components: [
{ type: 'BODY', text: 'Catalog' },
{ type: 'CATALOG', thumbnail_product_retailer_id: '123' },
],
},
{
name: 'regular_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Regular message' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: interactiveTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('filters out location templates', () => {
const locationTemplates = [
{
name: 'location_template',
status: 'approved',
components: [
{ type: 'HEADER', format: 'LOCATION' },
{ type: 'BODY', text: 'Location message' },
],
},
{
name: 'regular_template',
status: 'approved',
components: [
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
{ type: 'BODY', text: 'Regular message' },
],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: locationTemplates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('regular_template');
});
it('returns valid templates from fixture data', () => {
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: templates,
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
// All templates in fixtures should be approved and valid
expect(result.length).toBeGreaterThan(0);
// Verify all returned templates are approved
result.forEach(template => {
expect(template.status).toBe('approved');
expect(template.components).toBeDefined();
expect(Array.isArray(template.components)).toBe(true);
});
// Verify specific templates from fixtures are included
const templateNames = result.map(t => t.name);
expect(templateNames).toContain('sample_flight_confirmation');
expect(templateNames).toContain('sample_issue_resolution');
expect(templateNames).toContain('sample_shipping_confirmation');
expect(templateNames).toContain('no_variable_template');
expect(templateNames).toContain('order_confirmation');
});
it('prioritizes message_templates over additional_attributes.message_templates', () => {
const primaryTemplates = [
{
name: 'primary_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Primary' }],
},
];
const fallbackTemplates = [
{
name: 'fallback_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Fallback' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: primaryTemplates,
additional_attributes: {
message_templates: fallbackTemplates,
},
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('primary_template');
});
it('falls back to additional_attributes.message_templates when message_templates is null', () => {
const fallbackTemplates = [
{
name: 'fallback_template',
status: 'approved',
components: [{ type: 'BODY', text: 'Fallback' }],
},
];
const state = {
records: [
{
id: 1,
channel_type: 'Channel::Whatsapp',
message_templates: null,
additional_attributes: {
message_templates: fallbackTemplates,
},
},
],
};
const result = getters.getFilteredWhatsAppTemplates(state)(1);
expect(result).toHaveLength(1);
expect(result[0].name).toBe('fallback_template');
});
});
});
@@ -260,4 +260,285 @@ export const templates = [
],
rejected_reason: 'NONE',
},
{
name: 'order_confirmation',
status: 'approved',
category: 'TICKET_UPDATE',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'IMAGE',
example: {
header_handle: ['https://example.com/shoes.jpg'],
},
},
{
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'technician_visit',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Technician visit',
type: 'HEADER',
format: 'TEXT',
},
{
text: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Confirm',
type: 'QUICK_REPLY',
},
{
text: 'Reschedule',
type: 'QUICK_REPLY',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'event_invitation_static',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://events.example.com/register',
text: 'Visit website',
type: 'URL',
},
{
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
text: 'Get Directions',
type: 'URL',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'purchase_receipt',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'DOCUMENT',
},
{
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'discount_coupon',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Copy offer code',
type: 'COPY_CODE',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'support_callback',
status: 'approved',
category: 'UTILITY',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
text: 'Call Support',
type: 'PHONE_NUMBER',
phone_number: '+16506677566',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'training_video',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'VIDEO',
},
{
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'product_launch',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
type: 'HEADER',
format: 'IMAGE',
},
{
text: 'New arrival! Our stunning coat now available in {{color}} color.',
type: 'BODY',
},
{
text: 'Free shipping on orders over $100. Limited time offer.',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'greet',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hey {{customer_name}} how may I help you?',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'hello_world',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Hello World',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
type: 'BODY',
},
{
text: 'WhatsApp Business Platform sample message',
type: 'FOOTER',
},
],
rejected_reason: 'NONE',
},
{
name: 'feedback_request',
status: 'approved',
category: 'MARKETING',
language: 'en',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
type: 'BODY',
},
{
type: 'BUTTONS',
buttons: [
{
url: 'https://feedback.example.com/survey',
text: 'Leave Feedback',
type: 'URL',
},
],
},
],
rejected_reason: 'NONE',
},
{
name: 'address_update',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: 'Address update',
type: 'HEADER',
format: 'TEXT',
},
{
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
{
name: 'delivery_confirmation',
status: 'approved',
category: 'UTILITY',
language: 'en_US',
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
components: [
{
text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
type: 'BODY',
},
],
rejected_reason: 'NONE',
},
];
@@ -1,61 +0,0 @@
import { shallowMount } from '@vue/test-utils';
import TemplateParser from '../../../../dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
import { templates } from './fixtures';
import { nextTick } from 'vue';
const config = {
global: {
stubs: {
NextButton: { template: '<button />' },
WootInput: { template: '<input />' },
},
},
};
describe('#WhatsAppTemplates', () => {
it('returns all variables from a template string', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[0] },
});
await nextTick();
expect(wrapper.vm.variables).toEqual(['{{1}}', '{{2}}', '{{3}}']);
});
it('returns no variables from a template string if it does not contain variables', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[12] },
});
await nextTick();
expect(wrapper.vm.variables).toBeNull();
});
it('returns the body of a template', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[1] },
});
await nextTick();
const expectedOutput =
templates[1].components.find(i => i.type === 'BODY')?.text || '';
expect(wrapper.vm.templateString).toEqual(expectedOutput);
});
it('generates the templates from variable input', async () => {
const wrapper = shallowMount(TemplateParser, {
...config,
props: { template: templates[0] },
});
await nextTick();
// Instead of using `setData`, directly modify the `processedParams` using the component's logic
await wrapper.vm.$nextTick();
wrapper.vm.processedParams = { 1: 'abc', 2: 'xyz', 3: 'qwerty' };
await wrapper.vm.$nextTick();
const expectedOutput =
'Esta é a sua confirmação de voo para abc-xyz em qwerty.';
expect(wrapper.vm.processedString).toEqual(expectedOutput);
});
});
+28 -41
View File
@@ -1,53 +1,18 @@
class AutomationRuleListener < BaseListener
def conversation_updated(event)
return if performed_by_automation?(event)
conversation = event.data[:conversation]
account = conversation.account
changed_attributes = event.data[:changed_attributes]
return unless rule_present?('conversation_updated', account)
rules = current_account_rules('conversation_updated', account)
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
end
process_conversation_event(event, 'conversation_updated')
end
def conversation_created(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
account = conversation.account
changed_attributes = event.data[:changed_attributes]
return unless rule_present?('conversation_created', account)
rules = current_account_rules('conversation_created', account)
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
::AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
end
process_conversation_event(event, 'conversation_created')
end
def conversation_opened(event)
return if performed_by_automation?(event) || ignore_auto_reply_event?(event)
process_conversation_event(event, 'conversation_opened')
end
conversation = event.data[:conversation]
account = conversation.account
changed_attributes = event.data[:changed_attributes]
return unless rule_present?('conversation_opened', account)
rules = current_account_rules('conversation_opened', account)
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
end
def conversation_resolved(event)
process_conversation_event(event, 'conversation_resolved')
end
def message_created(event)
@@ -69,6 +34,28 @@ class AutomationRuleListener < BaseListener
end
end
private
def process_conversation_event(event, event_name)
return if performed_by_automation?(event)
auto_reply_skip_events = %w[conversation_created conversation_opened]
return if auto_reply_skip_events.include?(event_name) && ignore_auto_reply_event?(event)
conversation = event.data[:conversation]
account = conversation.account
changed_attributes = event.data[:changed_attributes]
return unless rule_present?(event_name, account)
rules = current_account_rules(event_name, account)
rules.each do |rule|
conditions_match = ::AutomationRules::ConditionsFilterService.new(rule, conversation, { changed_attributes: changed_attributes }).perform
AutomationRules::ActionService.new(rule, account, conversation).perform if conditions_match.present?
end
end
def rule_present?(event_name, account)
return if account.blank?
+39
View File
@@ -90,8 +90,47 @@ class ReportingEventListener < BaseListener
reporting_event.save!
end
def conversation_opened(event)
conversation = extract_conversation_and_account(event)[0]
# Find the most recent resolved event for this conversation
last_resolved_event = ReportingEvent.where(
conversation_id: conversation.id,
name: 'conversation_resolved'
).order(event_end_time: :desc).first
# For first-time openings, value is 0
# For reopenings, calculate time since resolution
if last_resolved_event
time_since_resolved = conversation.updated_at.to_i - last_resolved_event.event_end_time.to_i
business_hours_value = business_hours(conversation.inbox, last_resolved_event.event_end_time, conversation.updated_at)
start_time = last_resolved_event.event_end_time
else
time_since_resolved = 0
business_hours_value = 0
start_time = conversation.created_at
end
create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
end
private
def create_conversation_opened_event(conversation, time_since_resolved, business_hours_value, start_time)
reporting_event = ReportingEvent.new(
name: 'conversation_opened',
value: time_since_resolved,
value_in_business_hours: business_hours_value,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id,
conversation_id: conversation.id,
event_start_time: start_time,
event_end_time: conversation.updated_at
)
reporting_event.save!
end
def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is user interaction on the conversation
+6 -1
View File
@@ -62,7 +62,12 @@ class Attachment < ApplicationRecord
def thumb_url
return '' unless file.attached? && image?
url_for(file.representation(resize_to_fill: [250, nil]))
begin
url_for(file.representation(resize_to_fill: [250, nil]))
rescue ActiveStorage::UnrepresentableError => e
Rails.logger.warn "Unrepresentable image attachment: #{id} (#{file.filename}) - #{e.message}"
''
end
end
def with_attached_file?
+7 -35
View File
@@ -32,7 +32,6 @@ class Channel::Whatsapp < ApplicationRecord
validates :phone_number, presence: true, uniqueness: true
validate :validate_provider_config
before_save :setup_webhooks
after_create :sync_templates
before_destroy :teardown_webhooks
@@ -60,6 +59,13 @@ class Channel::Whatsapp < ApplicationRecord
delegate :media_url, to: :provider_service
delegate :api_headers, to: :provider_service
def setup_webhooks
perform_webhook_setup
rescue StandardError => e
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{e.message}"
prompt_reauthorization!
end
private
def ensure_webhook_verify_token
@@ -70,34 +76,6 @@ class Channel::Whatsapp < ApplicationRecord
errors.add(:provider_config, 'Invalid Credentials') unless provider_service.validate_provider_config?
end
def setup_webhooks
return unless should_setup_webhooks?
perform_webhook_setup
rescue StandardError => e
handle_webhook_setup_error(e)
end
def provider_config_changed?
will_save_change_to_provider_config?
end
def should_setup_webhooks?
whatsapp_cloud_provider? && embedded_signup_source? && webhook_config_present? && provider_config_changed?
end
def whatsapp_cloud_provider?
provider == 'whatsapp_cloud'
end
def embedded_signup_source?
provider_config['source'] == 'embedded_signup'
end
def webhook_config_present?
provider_config['business_account_id'].present? && provider_config['api_key'].present?
end
def perform_webhook_setup
business_account_id = provider_config['business_account_id']
api_key = provider_config['api_key']
@@ -105,12 +83,6 @@ class Channel::Whatsapp < ApplicationRecord
Whatsapp::WebhookSetupService.new(self, business_account_id, api_key).perform
end
def handle_webhook_setup_error(error)
Rails.logger.error "[WHATSAPP] Webhook setup failed: #{error.message}"
# Don't raise the error to prevent channel creation from failing
# Webhooks can be retried later
end
def teardown_webhooks
Whatsapp::WebhookTeardownService.new(self).perform
end
@@ -68,14 +68,23 @@ class Notification::PushNotificationService
WebPush.payload_send(**browser_push_payload(subscription))
Rails.logger.info("Browser push sent to #{user.email} with title #{push_message[:title]}")
rescue WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized => e
Rails.logger.info "WebPush subscription expired: #{e.message}"
subscription.destroy!
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout => e
Rails.logger.error "WebPush operation error: #{e.message}"
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: notification.account).capture_exception
true
handle_browser_push_error(e, subscription)
end
def handle_browser_push_error(error, subscription)
case error
when WebPush::ExpiredSubscription, WebPush::InvalidSubscription, WebPush::Unauthorized
Rails.logger.info "WebPush subscription expired: #{error.message}"
subscription.destroy!
when WebPush::TooManyRequests
Rails.logger.warn "WebPush rate limited for #{user.email} on account #{notification.account.id}: #{error.message}"
when Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout
Rails.logger.error "WebPush operation error: #{error.message}"
else
ChatwootExceptionTracker.new(error, account: notification.account).capture_exception
true
end
end
def send_fcm_push(subscription)
@@ -33,15 +33,14 @@ class Whatsapp::ChannelCreationService
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel = create_channel
channel = build_channel
create_inbox(channel)
channel.reload
channel
end
end
def create_channel
Channel::Whatsapp.create!(
def build_channel
Channel::Whatsapp.build(
account: @account,
phone_number: @phone_info[:phone_number],
provider: 'whatsapp_cloud',
@@ -11,16 +11,34 @@ class Whatsapp::EmbeddedSignupService
def perform
validate_parameters!
# Exchange code for user access token
access_token = Whatsapp::TokenExchangeService.new(@code).perform
access_token = exchange_code_for_token
phone_info = fetch_phone_info(access_token)
validate_token_access(access_token)
# Fetch phone information
phone_info = Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
channel = create_or_reauthorize_channel(access_token, phone_info)
channel.setup_webhooks
channel
# Validate token has access to the WABA
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e
end
private
def exchange_code_for_token
Whatsapp::TokenExchangeService.new(@code).perform
end
def fetch_phone_info(access_token)
Whatsapp::PhoneInfoService.new(@waba_id, @phone_number_id, access_token).perform
end
def validate_token_access(access_token)
Whatsapp::TokenValidationService.new(access_token, @waba_id).perform
end
# Reauthorization flow if inbox_id is present
def create_or_reauthorize_channel(access_token, phone_info)
if @inbox_id.present?
Whatsapp::ReauthorizationService.new(
account: @account,
@@ -29,17 +47,11 @@ class Whatsapp::EmbeddedSignupService
business_id: @business_id
).perform(access_token, phone_info)
else
# Create channel for new authorization
waba_info = { waba_id: @waba_id, business_name: phone_info[:business_name] }
Whatsapp::ChannelCreationService.new(@account, waba_info, phone_info, access_token).perform
end
rescue StandardError => e
Rails.logger.error("[WHATSAPP] Embedded signup failed: #{e.message}")
raise e
end
private
def validate_parameters!
missing_params = []
missing_params << 'code' if @code.blank?
@@ -86,6 +86,9 @@ class Whatsapp::TemplateParameterConverterService
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
body_params = convert_hash_to_body_params(legacy_params)
enhanced['body'] = body_params unless body_params.empty?
when NilClass
# Templates without parameters (nil processed_params)
# Return empty enhanced structure
else
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
end
@@ -63,6 +63,25 @@ FactoryBot.define do
],
'sub_category' => 'CUSTOM',
'parameter_format' => 'NAMED'
},
{
'name' => 'test_no_params_template',
'status' => 'APPROVED',
'category' => 'UTILITY',
'language' => 'en',
'namespace' => 'ed41a221_133a_4558_a1d6_192960e3aee9',
'id' => '9876543210987654',
'length' => 1,
'parameter_format' => 'POSITIONAL',
'previous_category' => 'MARKETING',
'sub_category' => 'CUSTOM',
'components' => [
{
'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂',
'type' => 'BODY'
}
],
'rejected_reason' => 'NONE'
}]
end
message_templates_last_updated { Time.now.utc }
+177
View File
@@ -0,0 +1,177 @@
require 'rails_helper'
RSpec.describe ReportingEventHelper, type: :helper do
describe '#last_non_human_activity' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
context 'when conversation has no events' do
it 'returns conversation created_at' do
expect(helper.last_non_human_activity(conversation)).to eq(conversation.created_at)
end
end
context 'when conversation has bot handoff event' do
let!(:handoff_event) do
create(:reporting_event,
name: 'conversation_bot_handoff',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 2.hours.ago)
end
it 'returns handoff event end time' do
expect(helper.last_non_human_activity(conversation).to_i).to eq(handoff_event.event_end_time.to_i)
end
end
context 'when conversation has bot resolved event' do
let!(:bot_resolved_event) do
create(:reporting_event,
name: 'conversation_bot_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 3.hours.ago)
end
it 'returns bot resolved event end time' do
expect(helper.last_non_human_activity(conversation).to_i).to eq(bot_resolved_event.event_end_time.to_i)
end
end
context 'when conversation is reopened after bot resolution' do
let(:creation_time) { 5.days.ago }
let(:bot_resolution_time) { 5.days.ago + 5.minutes }
let(:reopening_time) { 1.hour.ago }
let!(:conversation) do
create(:conversation,
account: account,
inbox: inbox,
assignee: user,
created_at: creation_time)
end
before do
# First opened event
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 0,
event_start_time: creation_time,
event_end_time: creation_time)
# Bot resolved event
create(:reporting_event,
name: 'conversation_bot_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_start_time: creation_time,
event_end_time: bot_resolution_time)
# Resolved event
create(:reporting_event,
name: 'conversation_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_start_time: creation_time,
event_end_time: bot_resolution_time)
# Reopened event
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: (reopening_time - bot_resolution_time).to_i,
event_start_time: bot_resolution_time,
event_end_time: reopening_time)
end
it 'returns the reopening event time, not the creation time' do
# This is the key test: last_non_human_activity should return the reopening time
# so that first response time is calculated from when the conversation was reopened,
# not from when it was originally created
expect(helper.last_non_human_activity(conversation).to_i).to eq(reopening_time.to_i)
# Verify it's not returning the creation time or bot resolution time
expect(helper.last_non_human_activity(conversation).to_i).not_to eq(creation_time.to_i)
expect(helper.last_non_human_activity(conversation).to_i).not_to eq(bot_resolution_time.to_i)
end
end
context 'when conversation has multiple types of events' do
let(:opened_event_time) { 1.hour.ago }
before do
create(:reporting_event,
name: 'conversation_bot_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 4.hours.ago)
create(:reporting_event,
name: 'conversation_bot_handoff',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 3.hours.ago)
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: opened_event_time)
end
it 'returns the most recent handoff or opened event' do
# opened_event is more recent than handoff_event
expect(helper.last_non_human_activity(conversation).to_i).to eq(opened_event_time.to_i)
end
end
context 'when conversation has multiple reopenings' do
let(:third_opened_time) { 30.minutes.ago }
before do
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 0,
event_end_time: 5.days.ago)
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 3600,
event_end_time: 2.days.ago)
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 7200,
event_end_time: third_opened_time)
end
it 'returns the most recent opened event' do
expect(helper.last_non_human_activity(conversation).to_i).to eq(third_opened_time.to_i)
end
end
end
end
+57 -68
View File
@@ -10,23 +10,6 @@ describe Webhooks::InstagramEventsJob do
end
let!(:account) { create(:account) }
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { create(:inbox, channel: instagram_channel, account: account, greeting_enabled: false) }
# Combined message events into one helper
let(:message_events) do
{
dm: build(:instagram_message_create_event).with_indifferent_access,
standby: build(:instagram_message_standby_event).with_indifferent_access,
unsend: build(:instagram_message_unsend_event).with_indifferent_access,
attachment: build(:instagram_message_attachment_event).with_indifferent_access,
story_mention: build(:instagram_story_mention_event).with_indifferent_access,
story_mention_echo: build(:instagram_story_mention_event_with_echo).with_indifferent_access,
messaging_seen: build(:messaging_seen_event).with_indifferent_access,
unsupported: build(:instagram_message_unsupported_event).with_indifferent_access
}
end
def return_object_for(sender_id)
{ name: 'Jane',
@@ -38,21 +21,19 @@ describe Webhooks::InstagramEventsJob do
describe '#perform' do
context 'when handling messaging events for Instagram via Facebook page' do
let!(:instagram_messenger_channel) { create(:channel_instagram_fb_page, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_messenger_inbox) { create(:inbox, channel: instagram_messenger_channel, account: account, greeting_enabled: false) }
let(:fb_object) { double }
before do
instagram_inbox.destroy
end
it 'creates incoming message in the instagram inbox' do
dm_event = build(:instagram_message_create_event).with_indifferent_access
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_messenger_inbox.reload
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
@@ -62,14 +43,14 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates standby message in the instagram inbox' do
standby_event = build(:instagram_message_standby_event).with_indifferent_access
sender_id = standby_event[:entry][0][:standby][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
sender_id = message_events[:standby][:entry][0][:standby][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:standby][:entry])
instagram_messenger_inbox.reload
instagram_webhook.perform_now(standby_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
@@ -81,9 +62,11 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
sender_id = unsend_event[:entry][0][:messaging][0][:sender][:id]
message = create(:message, inbox_id: instagram_messenger_inbox.id, source_id: 'message-id-to-delete')
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
sender_id = message_events[:unsend][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
@@ -96,7 +79,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_messenger_inbox.messages.count).to be 1
instagram_webhook.perform_now(message_events[:unsend][:entry])
instagram_webhook.perform_now(unsend_event[:entry])
expect(instagram_messenger_inbox.messages.last.content).to eq 'This message was deleted'
expect(instagram_messenger_inbox.messages.last.deleted).to be true
@@ -105,14 +88,14 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram inbox' do
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
sender_id = attachment_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
sender_id = message_events[:attachment][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:attachment][:entry])
instagram_messenger_inbox.reload
instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.messages.count).to be 1
@@ -120,8 +103,10 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram inbox for story mention' do
story_mention_event = build(:instagram_story_mention_event).with_indifferent_access
sender_id = story_mention_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
sender_id = message_events[:story_mention][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access,
{ story:
@@ -137,9 +122,7 @@ describe Webhooks::InstagramEventsJob do
id: 'instagram-message-id-1234' }.with_indifferent_access
)
instagram_webhook.perform_now(message_events[:story_mention][:entry])
instagram_messenger_inbox.reload
instagram_webhook.perform_now(story_mention_event[:entry])
expect(instagram_messenger_inbox.messages.count).to be 1
expect(instagram_messenger_inbox.messages.last.attachments.count).to be 1
@@ -149,12 +132,12 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Facebook API call fails' do
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_raise(Koala::Facebook::ClientError)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
instagram_messenger_inbox.reload
instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 0
expect(instagram_messenger_inbox.contact_inboxes.count).to be 0
@@ -162,21 +145,23 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_messenger_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'handles unsupported message' do
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
sender_id = unsupported_event[:entry][0][:messaging][0][:sender][:id]
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
sender_id = message_events[:unsupported][:entry][0][:messaging][0][:sender][:id]
allow(fb_object).to receive(:get_object).and_return(
return_object_for(sender_id).with_indifferent_access
)
instagram_webhook.perform_now(message_events[:unsupported][:entry])
instagram_messenger_inbox.reload
instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_messenger_inbox.contacts.count).to be 1
expect(instagram_messenger_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_messenger_inbox.conversations.count).to be 1
@@ -186,6 +171,9 @@ describe Webhooks::InstagramEventsJob do
end
context 'when handling messaging events for Instagram via Instagram login' do
let!(:instagram_channel) { create(:channel_instagram, account: account, instagram_id: 'chatwoot-app-user-id-1') }
let!(:instagram_inbox) { instagram_channel.inbox }
before do
instagram_channel.update(access_token: 'valid_instagram_token')
@@ -210,9 +198,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with correct contact info in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
dm_event = build(:instagram_message_create_event).with_indifferent_access
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to eq 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to eq 1
@@ -221,7 +208,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'sets correct instagram attributes on contact' do
instagram_webhook.perform_now(message_events[:dm][:entry])
dm_event = build(:instagram_message_create_event).with_indifferent_access
instagram_webhook.perform_now(dm_event[:entry])
instagram_inbox.reload
contact = instagram_inbox.contacts.last
@@ -233,6 +221,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'handle instagram unsend message event' do
unsend_event = build(:instagram_message_unsend_event).with_indifferent_access
message = create(:message, inbox_id: instagram_inbox.id, source_id: 'message-id-to-delete', content: 'random_text')
# Create attachment correctly with account association
@@ -244,7 +234,7 @@ describe Webhooks::InstagramEventsJob do
expect(instagram_inbox.messages.count).to be 1
instagram_webhook.perform_now(message_events[:unsend][:entry])
instagram_webhook.perform_now(unsend_event[:entry])
message.reload
@@ -254,9 +244,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'creates incoming message with attachments in the instagram direct inbox' do
instagram_webhook.perform_now(message_events[:attachment][:entry])
instagram_inbox.reload
attachment_event = build(:instagram_message_attachment_event).with_indifferent_access
instagram_webhook.perform_now(attachment_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.messages.count).to be 1
@@ -264,9 +253,8 @@ describe Webhooks::InstagramEventsJob do
end
it 'handles unsupported message' do
instagram_webhook.perform_now(message_events[:unsupported][:entry])
instagram_inbox.reload
unsupported_event = build(:instagram_message_unsupported_event).with_indifferent_access
instagram_webhook.perform_now(unsupported_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.additional_attributes['social_instagram_user_name']).to eq 'some_user_name'
expect(instagram_inbox.conversations.count).to be 1
@@ -275,12 +263,12 @@ describe Webhooks::InstagramEventsJob do
end
it 'does not create contact or messages when Instagram API call fails' do
story_mention_echo_event = build(:instagram_story_mention_event_with_echo).with_indifferent_access
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'Invalid OAuth access token' } }.to_json)
instagram_webhook.perform_now(message_events[:story_mention_echo][:entry])
instagram_inbox.reload
instagram_webhook.perform_now(story_mention_echo_event[:entry])
expect(instagram_inbox.contacts.count).to be 0
expect(instagram_inbox.contact_inboxes.count).to be 0
@@ -288,19 +276,20 @@ describe Webhooks::InstagramEventsJob do
end
it 'handles messaging_seen callback' do
expect(Instagram::ReadStatusService).to receive(:new).with(params: message_events[:messaging_seen][:entry][0][:messaging][0],
messaging_seen_event = build(:messaging_seen_event).with_indifferent_access
expect(Instagram::ReadStatusService).to receive(:new).with(params: messaging_seen_event[:entry][0][:messaging][0],
channel: instagram_inbox.channel).and_call_original
instagram_webhook.perform_now(message_events[:messaging_seen][:entry])
instagram_webhook.perform_now(messaging_seen_event[:entry])
end
it 'creates contact when Instagram API call returns `No matching Instagram user` (9010 error code)' do
stub_request(:get, %r{https://graph\.instagram\.com/v22\.0/.*\?.*})
.to_return(status: 401, body: { error: { message: 'No matching Instagram user', code: 9010 } }.to_json)
sender_id = message_events[:dm][:entry][0][:messaging][0][:sender][:id]
instagram_webhook.perform_now(message_events[:dm][:entry])
instagram_inbox.reload
dm_event = build(:instagram_message_create_event).with_indifferent_access
sender_id = dm_event[:entry][0][:messaging][0][:sender][:id]
instagram_webhook.perform_now(dm_event[:entry])
expect(instagram_inbox.contacts.count).to be 1
expect(instagram_inbox.contacts.last.name).to eq "Unknown (IG: #{sender_id})"
@@ -130,6 +130,42 @@ describe AutomationRuleListener do
end
end
describe 'conversation_resolved' do
let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_resolved', account: account) }
let(:event) do
Events::Base.new('conversation_resolved', Time.zone.now, { conversation: conversation,
changed_attributes: { status: %w[Snoozed Open] } })
end
context 'when matching rules are present' do
it 'calls AutomationRules::ActionService if conditions match' do
allow(condition_match).to receive(:present?).and_return(true)
listener.conversation_resolved(event)
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
end
it 'does not call AutomationRules::ActionService if conditions do not match' do
allow(condition_match).to receive(:present?).and_return(false)
listener.conversation_resolved(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
it 'calls AutomationRules::ActionService for each rule when multiple rules are present' do
create(:automation_rule, event_name: 'conversation_resolved', account: account)
allow(condition_match).to receive(:present?).and_return(true)
listener.conversation_resolved(event)
expect(AutomationRules::ActionService).to have_received(:new).twice
end
it 'does not call AutomationRules::ActionService if performed by automation' do
event.data[:performed_by] = automation_rule
allow(condition_match).to receive(:present?).and_return(true)
listener.conversation_resolved(event)
expect(AutomationRules::ActionService).not_to have_received(:new).with(automation_rule, account, conversation)
end
end
end
describe 'message_created' do
let!(:automation_rule) { create(:automation_rule, event_name: 'message_created', account: account) }
let!(:message) { create(:message, account: account, conversation: conversation) }
@@ -267,4 +267,177 @@ describe ReportingEventListener do
end
end
end
describe '#conversation_opened' do
context 'when conversation is opened for the first time' do
let(:new_conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
it 'creates conversation_opened event with value 0' do
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
event = Events::Base.new('conversation.opened', Time.zone.now, conversation: new_conversation)
listener.conversation_opened(event)
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
opened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(opened_event.value).to eq 0
expect(opened_event.value_in_business_hours).to eq 0
expect(opened_event.event_start_time).to be_within(1.second).of(new_conversation.created_at)
expect(opened_event.event_end_time).to be_within(1.second).of(new_conversation.updated_at)
end
end
context 'when conversation is reopened after being resolved' do
let(:resolved_time) { 2.hours.ago }
let(:reopened_time) { 1.hour.ago }
let(:reopened_conversation) do
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
end
before do
# Create a resolved event first
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: reopened_conversation.id,
user_id: user.id,
value: 3600,
event_start_time: reopened_conversation.created_at,
event_end_time: resolved_time)
end
it 'creates conversation_opened event' do
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 0
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
expect(account.reporting_events.where(name: 'conversation_opened').count).to be 1
end
it 'calculates correct time since resolution' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(3600) # 1 hour = 3600 seconds
expect(reopened_event.event_start_time).to be_within(1.second).of(resolved_time)
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
end
it 'sets correct attributes for conversation_opened event' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: reopened_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.account_id).to eq(account.id)
expect(reopened_event.inbox_id).to eq(inbox.id)
expect(reopened_event.conversation_id).to eq(reopened_conversation.id)
expect(reopened_event.user_id).to eq(user.id)
end
context 'when business hours enabled for inbox' do
let(:resolved_time) { Time.zone.parse('March 20, 2022 12:00') }
let(:reopened_time) { Time.zone.parse('March 21, 2022 14:00') }
let!(:business_hours_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
let!(:business_hours_conversation) do
create(:conversation, account: account, inbox: business_hours_inbox, assignee: user, updated_at: reopened_time)
end
before do
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: business_hours_inbox.id,
conversation_id: business_hours_conversation.id,
user_id: user.id,
value: 3600,
event_start_time: business_hours_conversation.created_at,
event_end_time: resolved_time)
end
it 'creates conversation_opened event with business hour value' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: business_hours_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value_in_business_hours).to be 18_000.0 # 5 business hours (26 hours total - 21 non-business hours)
end
end
end
context 'when conversation has multiple resolutions' do
let(:first_resolved_time) { 3.hours.ago }
let(:second_resolved_time) { 1.hour.ago }
let(:reopened_time) { 30.minutes.ago }
let(:multiple_resolution_conversation) do
create(:conversation, account: account, inbox: inbox, assignee: user, updated_at: reopened_time)
end
before do
# Create first resolved event
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: multiple_resolution_conversation.id,
user_id: user.id,
value: 3600,
event_start_time: multiple_resolution_conversation.created_at,
event_end_time: first_resolved_time)
# Create second resolved event (more recent)
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: inbox.id,
conversation_id: multiple_resolution_conversation.id,
user_id: user.id,
value: 1800,
event_start_time: first_resolved_time,
event_end_time: second_resolved_time)
end
it 'uses the most recent resolved event for calculation' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: multiple_resolution_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(1800) # 30 minutes from second resolution
expect(reopened_event.event_start_time).to be_within(1.second).of(second_resolved_time)
end
end
context 'when agent bot resolves and conversation is reopened' do
# This implicitly tests that the first_response time is correctly calculated
# By checking that a conversation reopened event is created with the correct values
let(:agent_bot) { create(:agent_bot, account: account) }
let(:agent_bot_inbox) { create(:inbox, account: account) }
let(:bot_resolved_time) { 2.hours.ago }
let(:reopened_time) { 1.hour.ago }
let(:bot_conversation) do
create(:conversation, account: account, inbox: agent_bot_inbox, assignee: user, updated_at: reopened_time)
end
before do
create(:agent_bot_inbox, agent_bot: agent_bot, inbox: agent_bot_inbox)
create(:reporting_event,
name: 'conversation_resolved',
account_id: account.id,
inbox_id: agent_bot_inbox.id,
conversation_id: bot_conversation.id,
user_id: user.id,
event_end_time: bot_resolved_time)
end
it 'creates conversation_opened event for agent bot reopening' do
event = Events::Base.new('conversation.opened', reopened_time, conversation: bot_conversation)
listener.conversation_opened(event)
reopened_event = account.reporting_events.where(name: 'conversation_opened').first
expect(reopened_event.value).to be_within(1).of(3600) # 1 hour since resolution
expect(reopened_event.event_start_time).to be_within(1.second).of(bot_resolved_time)
expect(reopened_event.event_end_time).to be_within(1.second).of(reopened_time)
end
end
end
end
+10
View File
@@ -82,6 +82,16 @@ RSpec.describe Attachment do
expect(attachment.thumb_url).to be_present
end
it 'handles unrepresentable images gracefully' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
expect(attachment.thumb_url).to eq('')
end
end
describe 'meta data handling' do
@@ -16,6 +16,11 @@ describe Whatsapp::ChannelCreationService do
describe '#perform' do
before do
# Stub the webhook teardown service to prevent HTTP calls during cleanup
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
allow(Whatsapp::WebhookTeardownService).to receive(:new).and_return(teardown_service)
allow(teardown_service).to receive(:perform)
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
@@ -10,121 +10,100 @@ describe Whatsapp::EmbeddedSignupService do
phone_number_id: 'test_phone_number_id'
}
end
let(:service) do
described_class.new(
account: account,
params: params
)
let(:service) { described_class.new(account: account, params: params) }
let(:access_token) { 'test_access_token' }
let(:phone_info) do
{
phone_number_id: params[:phone_number_id],
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
}
end
let(:channel) { instance_double(Channel::Whatsapp) }
describe '#perform' do
let(:access_token) { 'test_access_token' }
let(:phone_info) do
{
phone_number_id: params[:phone_number_id],
phone_number: '+1234567890',
verified: true,
business_name: 'Test Business'
}
end
let(:channel) { instance_double(Channel::Whatsapp) }
let(:service_doubles) do
{
token_exchange: instance_double(Whatsapp::TokenExchangeService),
phone_info: instance_double(Whatsapp::PhoneInfoService),
token_validation: instance_double(Whatsapp::TokenValidationService),
channel_creation: instance_double(Whatsapp::ChannelCreationService)
}
end
before do
allow(GlobalConfig).to receive(:clear_cache)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(service_doubles[:token_exchange])
allow(service_doubles[:token_exchange]).to receive(:perform).and_return(access_token)
# Mock service dependencies
token_exchange = instance_double(Whatsapp::TokenExchangeService)
allow(Whatsapp::TokenExchangeService).to receive(:new).with(params[:code]).and_return(token_exchange)
allow(token_exchange).to receive(:perform).and_return(access_token)
phone_service = instance_double(Whatsapp::PhoneInfoService)
allow(Whatsapp::PhoneInfoService).to receive(:new)
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(service_doubles[:phone_info])
allow(service_doubles[:phone_info]).to receive(:perform).and_return(phone_info)
.with(params[:waba_id], params[:phone_number_id], access_token).and_return(phone_service)
allow(phone_service).to receive(:perform).and_return(phone_info)
validation_service = instance_double(Whatsapp::TokenValidationService)
allow(Whatsapp::TokenValidationService).to receive(:new)
.with(access_token, params[:waba_id]).and_return(service_doubles[:token_validation])
allow(service_doubles[:token_validation]).to receive(:perform)
.with(access_token, params[:waba_id]).and_return(validation_service)
allow(validation_service).to receive(:perform)
channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new)
.with(account, { waba_id: params[:waba_id], business_name: 'Test Business' }, phone_info, access_token)
.and_return(service_doubles[:channel_creation])
allow(service_doubles[:channel_creation]).to receive(:perform).and_return(channel)
.and_return(channel_creation)
allow(channel_creation).to receive(:perform).and_return(channel)
# Webhook setup is now handled in the channel after_create callback
# So we stub it at the model level
webhook_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
allow(channel).to receive(:setup_webhooks)
end
it 'orchestrates all services in the correct order' do
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
expect(service_doubles[:phone_info]).to receive(:perform).ordered
expect(service_doubles[:token_validation]).to receive(:perform).ordered
expect(service_doubles[:channel_creation]).to receive(:perform).ordered
it 'creates channel and sets up webhooks' do
expect(channel).to receive(:setup_webhooks)
result = service.perform
expect(result).to eq(channel)
end
context 'when required parameters are missing' do
it 'raises error when code is blank' do
service = described_class.new(
account: account,
params: params.merge(code: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code/)
end
it 'raises error when business_id is blank' do
service = described_class.new(
account: account,
params: params.merge(business_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: business_id/)
end
it 'raises error when waba_id is blank' do
service = described_class.new(
account: account,
params: params.merge(waba_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: waba_id/)
end
it 'raises error when multiple parameters are blank' do
service = described_class.new(
account: account,
params: params.merge(code: '', business_id: '')
)
expect { service.perform }.to raise_error(ArgumentError, /Required parameters are missing: code, business_id/)
context 'when parameters are invalid' do
it 'raises ArgumentError for missing parameters' do
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
expect { invalid_service.perform }.to raise_error(ArgumentError, /Required parameters are missing/)
end
end
context 'when any service fails' do
it 'logs and re-raises the error' do
allow(service_doubles[:token_exchange]).to receive(:perform).and_raise('Token error')
context 'when service fails' do
it 'logs and re-raises errors' do
token_exchange = instance_double(Whatsapp::TokenExchangeService)
allow(Whatsapp::TokenExchangeService).to receive(:new).and_return(token_exchange)
allow(token_exchange).to receive(:perform).and_raise('Token error')
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Embedded signup failed: Token error')
expect { service.perform }.to raise_error('Token error')
end
it 'prompts reauthorization when webhook setup fails' do
# Create a real channel to test the actual webhook failure behavior
real_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
validate_provider_config: false, sync_templates: false)
# Mock the channel creation to return our real channel
channel_creation = instance_double(Whatsapp::ChannelCreationService)
allow(Whatsapp::ChannelCreationService).to receive(:new).and_return(channel_creation)
allow(channel_creation).to receive(:perform).and_return(real_channel)
# Mock webhook setup to fail
allow(real_channel).to receive(:perform_webhook_setup).and_raise('Webhook setup error')
# Verify channel is not marked for reauthorization initially
expect(real_channel.reauthorization_required?).to be false
# The service completes successfully even if webhook fails (webhook error is rescued in setup_webhooks)
result = service.perform
expect(result).to eq(real_channel)
# Verify the channel is now marked for reauthorization
expect(real_channel.reauthorization_required?).to be true
end
end
context 'when inbox_id is provided (reauthorization flow)' do
context 'with reauthorization flow' do
let(:inbox_id) { 123 }
let(:reauth_service) { instance_double(Whatsapp::ReauthorizationService) }
let(:service_with_inbox) do
described_class.new(
account: account,
params: params,
inbox_id: inbox_id
)
described_class.new(account: account, params: params, inbox_id: inbox_id)
end
before do
@@ -137,16 +116,45 @@ describe Whatsapp::EmbeddedSignupService do
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
end
it 'uses ReauthorizationService instead of ChannelCreationService' do
expect(service_doubles[:token_exchange]).to receive(:perform).ordered
expect(service_doubles[:phone_info]).to receive(:perform).ordered
expect(service_doubles[:token_validation]).to receive(:perform).ordered
expect(reauth_service).to receive(:perform).with(access_token, phone_info).ordered
expect(service_doubles[:channel_creation]).not_to receive(:perform)
it 'uses ReauthorizationService and sets up webhooks' do
expect(reauth_service).to receive(:perform)
expect(channel).to receive(:setup_webhooks)
result = service_with_inbox.perform
expect(result).to eq(channel)
end
it 'clears reauthorization flag' do
inbox = create(:inbox, account: account)
whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
validate_provider_config: false, sync_templates: false)
inbox.update!(channel: whatsapp_channel)
whatsapp_channel.prompt_reauthorization!
service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id)
# Mock the ReauthorizationService to return our test channel
reauth_service = instance_double(Whatsapp::ReauthorizationService)
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
account: account,
inbox_id: inbox.id,
phone_number_id: params[:phone_number_id],
business_id: params[:business_id]
).and_return(reauth_service)
# Perform the reauthorization and clear the flag
allow(reauth_service).to receive(:perform) do
whatsapp_channel.reauthorized!
whatsapp_channel
end
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
expect(whatsapp_channel.reauthorization_required?).to be true
result = service_with_real_inbox.perform
expect(result).to eq(whatsapp_channel)
expect(whatsapp_channel.reauthorization_required?).to be false
end
end
end
end
@@ -133,6 +133,48 @@ describe Whatsapp::TemplateParameterConverterService do
end
end
context 'when processed_params is nil (parameter-less templates)' do
let(:nil_params) do
{
'processed_params' => nil
}
end
let(:parameterless_template) do
{
'name' => 'test_no_params_template',
'language' => 'en',
'parameter_format' => 'POSITIONAL',
'id' => '9876543210987654',
'status' => 'APPROVED',
'category' => 'UTILITY',
'previous_category' => 'MARKETING',
'sub_category' => 'CUSTOM',
'components' => [
{
'type' => 'BODY',
'text' => 'Thank you for contacting us! Your request has been processed successfully. Have a great day! 🙂'
}
]
}
end
it 'converts nil to empty enhanced format' do
converter = described_class.new(nil_params, parameterless_template)
result = converter.normalize_to_enhanced
expect(result['processed_params']).to eq({})
expect(result['format_version']).to eq('legacy')
end
it 'does not raise ArgumentError for nil processed_params' do
expect do
converter = described_class.new(nil_params, parameterless_template)
converter.normalize_to_enhanced
end.not_to raise_error
end
end
context 'when invalid format' do
let(:invalid_params) do
{
@@ -174,6 +216,26 @@ describe Whatsapp::TemplateParameterConverterService do
end
describe 'simplified conversion methods' do
describe '#convert_legacy_to_enhanced' do
it 'handles nil processed_params without raising error' do
converter = described_class.new({}, template)
result = converter.send(:convert_legacy_to_enhanced, nil, template)
expect(result).to eq({})
end
it 'returns empty hash for parameter-less templates' do
parameterless_template = {
'name' => 'no_params_template',
'language' => 'en',
'components' => [{ 'type' => 'BODY', 'text' => 'Hello World!' }]
}
converter = described_class.new({}, parameterless_template)
result = converter.send(:convert_legacy_to_enhanced, nil, parameterless_template)
expect(result).to eq({})
end
end
describe '#convert_array_to_body_params' do
it 'converts empty array' do
converter = described_class.new({}, template)
@@ -5,7 +5,7 @@ describe Whatsapp::WebhookSetupService do
create(:channel_whatsapp,
phone_number: '+1234567890',
provider_config: {
'phone_number_id' => 'test_phone_id',
'phone_number_id' => '123456789',
'webhook_verify_token' => 'test_verify_token'
},
provider: 'whatsapp_cloud',
@@ -18,9 +18,14 @@ describe Whatsapp::WebhookSetupService do
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
before do
# Stub webhook teardown to prevent HTTP calls during cleanup
stub_request(:delete, /graph.facebook.com/).to_return(status: 200, body: '{}', headers: {})
# Clean up any existing channels to avoid phone number conflicts
Channel::Whatsapp.destroy_all
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
# Default stub for phone_number_verified? with any argument
allow(api_client).to receive(:phone_number_verified?).and_return(false)
end
describe '#perform' do
@@ -148,5 +153,87 @@ describe Whatsapp::WebhookSetupService do
end
end
end
context 'when webhook setup fails and should trigger reauthorization' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(api_client).to receive(:subscribe_waba_webhook).and_raise('Invalid access token')
end
it 'raises error with webhook setup failure message' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect { service.perform }.to raise_error(/Webhook setup failed: Invalid access token/)
end
end
it 'logs the webhook setup failure' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(Rails.logger).to receive(:error).with('[WHATSAPP] Webhook setup failed: Invalid access token')
expect { service.perform }.to raise_error(/Webhook setup failed/)
end
end
end
context 'when used during reauthorization flow' do
let(:existing_channel) do
create(:channel_whatsapp,
phone_number: '+1234567890',
provider_config: {
'phone_number_id' => '123456789',
'webhook_verify_token' => 'existing_verify_token',
'business_id' => 'existing_business_id',
'waba_id' => 'existing_waba_id'
},
provider: 'whatsapp_cloud',
sync_templates: false,
validate_provider_config: false)
end
let(:new_access_token) { 'new_access_token' }
let(:service_reauth) { described_class.new(existing_channel, waba_id, new_access_token) }
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
end
it 'successfully reauthorizes with new access token' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).not_to receive(:register_phone_number)
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'existing_verify_token')
service_reauth.perform
end
end
it 'uses the existing webhook verify token during reauthorization' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'existing_verify_token')
service_reauth.perform
end
end
end
context 'when webhook setup is successful in creation flow' do
before do
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
allow(api_client).to receive(:subscribe_waba_webhook)
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
end
it 'completes successfully without errors' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect { service.perform }.not_to raise_error
end
end
it 'does not log any errors' do
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
expect(Rails.logger).not_to receive(:error)
service.perform
end
end
end
end
end
@@ -13,6 +13,7 @@ properties:
enum:
- conversation_created
- conversation_updated
- conversation_resolved
- message_created
example: message_created
description: The event when you want to execute the automation actions
@@ -30,22 +30,64 @@ properties:
example: 1
template_params:
type: object
description: The template params for the message in case of whatsapp Channel
description: WhatsApp template parameters for sending structured messages
required:
- name
- category
- language
- processed_params
properties:
name:
type: string
description: Name of the template
example: 'sample_issue_resolution'
description: Name of the WhatsApp template (must be approved in WhatsApp Business Manager)
example: 'purchase_receipt'
category:
type: string
enum: ['UTILITY', 'MARKETING', 'SHIPPING_UPDATE', 'TICKET_UPDATE', 'ISSUE_RESOLUTION']
description: Category of the template
example: UTILITY
example: 'UTILITY'
language:
type: string
description: Language of the template
example: en_US
description: Language code of the template (BCP 47 format)
example: 'en_US'
processed_params:
type: object
description: The processed param values for template variables in template
example:
1: 'Chatwoot'
description: Processed template parameters organized by component type
properties:
body:
type: object
description: Body component parameters with variable placeholders
additionalProperties:
type: string
example:
'1': 'Visa'
'2': 'Nike'
'3': 'Bill'
header:
type: object
description: Header component parameters for media templates
properties:
media_url:
type: string
format: uri
description: Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers
example: 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
media_type:
type: string
enum: ['image', 'video', 'document']
description: Type of media for the header
example: 'document'
buttons:
type: array
description: Button component parameters for interactive templates
items:
type: object
properties:
type:
type: string
enum: ['url', 'copy_code']
description: Type of button parameter
parameter:
type: string
description: Dynamic parameter value for the button
example: 'SSFSDFSD'
@@ -10,4 +10,4 @@ properties:
- type: object
description: Single automation rule (for show/create/update endpoints)
allOf:
- $ref: '#/components/schemas/automation_rule_item'
- $ref: '#/components/schemas/automation_rule_item'
@@ -2,7 +2,57 @@ tags:
- Messages
operationId: create-a-new-message-in-a-conversation
summary: Create New Message
description: Create a new message in the conversation
description: |
Create a new message in the conversation.
## WhatsApp Template Messages
For WhatsApp channels, you can send structured template messages using the `template_params` field.
Templates must be pre-approved in WhatsApp Business Manager.
### Example Templates
**Text with Image Header:**
```json
{
"content": "Hi your order 121212 is confirmed. Please wait for further updates",
"template_params": {
"name": "order_confirmation",
"category": "MARKETING",
"language": "en",
"processed_params": {
"body": {
"1": "121212"
},
"header": {
"media_url": "https://picsum.photos/200/300",
"media_type": "image"
}
}
}
}
```
**Text with Copy Code Button:**
```json
{
"content": "Special offer! Get 30% off your next purchase. Use the code below",
"template_params": {
"name": "discount_coupon",
"category": "MARKETING",
"language": "en",
"processed_params": {
"body": {
"discount_percentage": "30"
},
"buttons": [{
"type": "copy_code",
"parameter": "SAVE20"
}]
}
}
}
```
security:
- userApiKey: []
- agentBotApiKey: []
+77 -8
View File
@@ -5937,7 +5937,7 @@
],
"operationId": "create-a-new-message-in-a-conversation",
"summary": "Create New Message",
"description": "Create a new message in the conversation",
"description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
"security": [
{
"userApiKey": []
@@ -10148,28 +10148,96 @@
},
"template_params": {
"type": "object",
"description": "The template params for the message in case of whatsapp Channel",
"description": "WhatsApp template parameters for sending structured messages",
"required": [
"name",
"category",
"language",
"processed_params"
],
"properties": {
"name": {
"type": "string",
"description": "Name of the template",
"example": "sample_issue_resolution"
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
"example": "purchase_receipt"
},
"category": {
"type": "string",
"enum": [
"UTILITY",
"MARKETING",
"SHIPPING_UPDATE",
"TICKET_UPDATE",
"ISSUE_RESOLUTION"
],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
"description": "Language of the template",
"description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
"description": "The processed param values for template variables in template",
"example": {
"1": "Chatwoot"
"description": "Processed template parameters organized by component type",
"properties": {
"body": {
"type": "object",
"description": "Body component parameters with variable placeholders",
"additionalProperties": {
"type": "string"
},
"example": {
"1": "Visa",
"2": "Nike",
"3": "Bill"
}
},
"header": {
"type": "object",
"description": "Header component parameters for media templates",
"properties": {
"media_url": {
"type": "string",
"format": "uri",
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
},
"media_type": {
"type": "string",
"enum": [
"image",
"video",
"document"
],
"description": "Type of media for the header",
"example": "document"
}
}
},
"buttons": {
"type": "array",
"description": "Button component parameters for interactive templates",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"url",
"copy_code"
],
"description": "Type of button parameter"
},
"parameter": {
"type": "string",
"description": "Dynamic parameter value for the button",
"example": "SSFSDFSD"
}
}
}
}
}
}
}
@@ -10542,6 +10610,7 @@
"enum": [
"conversation_created",
"conversation_updated",
"conversation_resolved",
"message_created"
],
"example": "message_created",
+77 -8
View File
@@ -4334,7 +4334,7 @@
],
"operationId": "create-a-new-message-in-a-conversation",
"summary": "Create New Message",
"description": "Create a new message in the conversation",
"description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n",
"security": [
{
"userApiKey": []
@@ -8509,28 +8509,96 @@
},
"template_params": {
"type": "object",
"description": "The template params for the message in case of whatsapp Channel",
"description": "WhatsApp template parameters for sending structured messages",
"required": [
"name",
"category",
"language",
"processed_params"
],
"properties": {
"name": {
"type": "string",
"description": "Name of the template",
"example": "sample_issue_resolution"
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
"example": "purchase_receipt"
},
"category": {
"type": "string",
"enum": [
"UTILITY",
"MARKETING",
"SHIPPING_UPDATE",
"TICKET_UPDATE",
"ISSUE_RESOLUTION"
],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
"description": "Language of the template",
"description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
"description": "The processed param values for template variables in template",
"example": {
"1": "Chatwoot"
"description": "Processed template parameters organized by component type",
"properties": {
"body": {
"type": "object",
"description": "Body component parameters with variable placeholders",
"additionalProperties": {
"type": "string"
},
"example": {
"1": "Visa",
"2": "Nike",
"3": "Bill"
}
},
"header": {
"type": "object",
"description": "Header component parameters for media templates",
"properties": {
"media_url": {
"type": "string",
"format": "uri",
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
},
"media_type": {
"type": "string",
"enum": [
"image",
"video",
"document"
],
"description": "Type of media for the header",
"example": "document"
}
}
},
"buttons": {
"type": "array",
"description": "Button component parameters for interactive templates",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"url",
"copy_code"
],
"description": "Type of button parameter"
},
"parameter": {
"type": "string",
"description": "Dynamic parameter value for the button",
"example": "SSFSDFSD"
}
}
}
}
}
}
}
@@ -8903,6 +8971,7 @@
"enum": [
"conversation_created",
"conversation_updated",
"conversation_resolved",
"message_created"
],
"example": "message_created",
+76 -7
View File
@@ -3132,28 +3132,96 @@
},
"template_params": {
"type": "object",
"description": "The template params for the message in case of whatsapp Channel",
"description": "WhatsApp template parameters for sending structured messages",
"required": [
"name",
"category",
"language",
"processed_params"
],
"properties": {
"name": {
"type": "string",
"description": "Name of the template",
"example": "sample_issue_resolution"
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
"example": "purchase_receipt"
},
"category": {
"type": "string",
"enum": [
"UTILITY",
"MARKETING",
"SHIPPING_UPDATE",
"TICKET_UPDATE",
"ISSUE_RESOLUTION"
],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
"description": "Language of the template",
"description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
"description": "The processed param values for template variables in template",
"example": {
"1": "Chatwoot"
"description": "Processed template parameters organized by component type",
"properties": {
"body": {
"type": "object",
"description": "Body component parameters with variable placeholders",
"additionalProperties": {
"type": "string"
},
"example": {
"1": "Visa",
"2": "Nike",
"3": "Bill"
}
},
"header": {
"type": "object",
"description": "Header component parameters for media templates",
"properties": {
"media_url": {
"type": "string",
"format": "uri",
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
},
"media_type": {
"type": "string",
"enum": [
"image",
"video",
"document"
],
"description": "Type of media for the header",
"example": "document"
}
}
},
"buttons": {
"type": "array",
"description": "Button component parameters for interactive templates",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"url",
"copy_code"
],
"description": "Type of button parameter"
},
"parameter": {
"type": "string",
"description": "Dynamic parameter value for the button",
"example": "SSFSDFSD"
}
}
}
}
}
}
}
@@ -3526,6 +3594,7 @@
"enum": [
"conversation_created",
"conversation_updated",
"conversation_resolved",
"message_created"
],
"example": "message_created",
+76 -7
View File
@@ -2547,28 +2547,96 @@
},
"template_params": {
"type": "object",
"description": "The template params for the message in case of whatsapp Channel",
"description": "WhatsApp template parameters for sending structured messages",
"required": [
"name",
"category",
"language",
"processed_params"
],
"properties": {
"name": {
"type": "string",
"description": "Name of the template",
"example": "sample_issue_resolution"
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
"example": "purchase_receipt"
},
"category": {
"type": "string",
"enum": [
"UTILITY",
"MARKETING",
"SHIPPING_UPDATE",
"TICKET_UPDATE",
"ISSUE_RESOLUTION"
],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
"description": "Language of the template",
"description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
"description": "The processed param values for template variables in template",
"example": {
"1": "Chatwoot"
"description": "Processed template parameters organized by component type",
"properties": {
"body": {
"type": "object",
"description": "Body component parameters with variable placeholders",
"additionalProperties": {
"type": "string"
},
"example": {
"1": "Visa",
"2": "Nike",
"3": "Bill"
}
},
"header": {
"type": "object",
"description": "Header component parameters for media templates",
"properties": {
"media_url": {
"type": "string",
"format": "uri",
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
},
"media_type": {
"type": "string",
"enum": [
"image",
"video",
"document"
],
"description": "Type of media for the header",
"example": "document"
}
}
},
"buttons": {
"type": "array",
"description": "Button component parameters for interactive templates",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"url",
"copy_code"
],
"description": "Type of button parameter"
},
"parameter": {
"type": "string",
"description": "Dynamic parameter value for the button",
"example": "SSFSDFSD"
}
}
}
}
}
}
}
@@ -2941,6 +3009,7 @@
"enum": [
"conversation_created",
"conversation_updated",
"conversation_resolved",
"message_created"
],
"example": "message_created",
+76 -7
View File
@@ -3308,28 +3308,96 @@
},
"template_params": {
"type": "object",
"description": "The template params for the message in case of whatsapp Channel",
"description": "WhatsApp template parameters for sending structured messages",
"required": [
"name",
"category",
"language",
"processed_params"
],
"properties": {
"name": {
"type": "string",
"description": "Name of the template",
"example": "sample_issue_resolution"
"description": "Name of the WhatsApp template (must be approved in WhatsApp Business Manager)",
"example": "purchase_receipt"
},
"category": {
"type": "string",
"enum": [
"UTILITY",
"MARKETING",
"SHIPPING_UPDATE",
"TICKET_UPDATE",
"ISSUE_RESOLUTION"
],
"description": "Category of the template",
"example": "UTILITY"
},
"language": {
"type": "string",
"description": "Language of the template",
"description": "Language code of the template (BCP 47 format)",
"example": "en_US"
},
"processed_params": {
"type": "object",
"description": "The processed param values for template variables in template",
"example": {
"1": "Chatwoot"
"description": "Processed template parameters organized by component type",
"properties": {
"body": {
"type": "object",
"description": "Body component parameters with variable placeholders",
"additionalProperties": {
"type": "string"
},
"example": {
"1": "Visa",
"2": "Nike",
"3": "Bill"
}
},
"header": {
"type": "object",
"description": "Header component parameters for media templates",
"properties": {
"media_url": {
"type": "string",
"format": "uri",
"description": "Publicly accessible URL for IMAGE, VIDEO, or DOCUMENT headers",
"example": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf"
},
"media_type": {
"type": "string",
"enum": [
"image",
"video",
"document"
],
"description": "Type of media for the header",
"example": "document"
}
}
},
"buttons": {
"type": "array",
"description": "Button component parameters for interactive templates",
"items": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"url",
"copy_code"
],
"description": "Type of button parameter"
},
"parameter": {
"type": "string",
"description": "Dynamic parameter value for the button",
"example": "SSFSDFSD"
}
}
}
}
}
}
}
@@ -3702,6 +3770,7 @@
"enum": [
"conversation_created",
"conversation_updated",
"conversation_resolved",
"message_created"
],
"example": "message_created",