feat: WhatsApp campaigns (#11910)
# Pull Request Template ## Description This PR adds support for WhatsApp campaigns to Chatwoot, allowing businesses to reach their customers through WhatsApp. The implementation includes backend support for WhatsApp template messages, frontend UI components, and integration with the existing campaign system. Fixes #8465 Fixes https://linear.app/chatwoot/issue/CW-3390/whatsapp-campaigns ## Type of change - [x] New feature (non-breaking change which adds functionality) - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update ## How Has This Been Tested? - Tested WhatsApp campaign creation UI flow - Verified backend API endpoints for campaign creation - Tested campaign service integration with WhatsApp templates - Validated proper filtering of WhatsApp campaigns in the store ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] I have commented on my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published in downstream modules ## What we have changed: We have added support for WhatsApp campaigns as requested in the discussion. Ref: https://github.com/orgs/chatwoot/discussions/8465 **Note:** This implementation doesn't exactly match the maintainer's specification and variable support is missing. This is an initial implementation that provides the core WhatsApp campaign functionality. ### Changes included: **Backend:** - Added `template_params` column to campaigns table (migration + schema) - Created `Whatsapp::OneoffCampaignService` for WhatsApp campaign execution - Updated campaign model to support WhatsApp inbox types - Added template_params support to campaign controller and API **Frontend:** - Added WhatsApp campaign page, dialog, and form components - Updated campaign store to filter WhatsApp campaigns separately - Added WhatsApp-specific routes and empty state - Updated i18n translations for WhatsApp campaigns - Modified sidebar to include WhatsApp campaigns navigation This provides a foundation for WhatsApp campaigns that can be extended with variable support and other enhancements in future iterations. --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -29,6 +29,6 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def campaign_params
|
||||
params.require(:campaign).permit(:title, :description, :message, :enabled, :trigger_only_during_business_hours, :inbox_id, :sender_id,
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {})
|
||||
:scheduled_at, audience: [:type, :id], trigger_rules: {}, template_params: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup>
|
||||
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
|
||||
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
|
||||
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EmptyStateLayout :title="title" :subtitle="subtitle">
|
||||
<template #empty-state-item>
|
||||
<div class="flex flex-col gap-4 p-px">
|
||||
<CampaignCard
|
||||
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
|
||||
:key="campaign.id"
|
||||
:title="campaign.title"
|
||||
:message="campaign.message"
|
||||
:is-enabled="campaign.enabled"
|
||||
:status="campaign.campaign_status"
|
||||
:sender="campaign.sender"
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</EmptyStateLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
|
||||
|
||||
import WhatsAppCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue';
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const addCampaign = async campaignDetails => {
|
||||
try {
|
||||
await store.dispatch('campaigns/create', campaignDetails);
|
||||
|
||||
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
|
||||
type: CAMPAIGN_TYPES.ONE_OFF,
|
||||
});
|
||||
|
||||
useAlert(t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = campaignDetails => {
|
||||
addCampaign(campaignDetails);
|
||||
};
|
||||
|
||||
const handleClose = () => emit('close');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-n-weak shadow-md flex flex-col gap-6"
|
||||
>
|
||||
<h3 class="text-base font-medium text-n-slate-12">
|
||||
{{ t(`CAMPAIGN.WHATSAPP.CREATE.TITLE`) }}
|
||||
</h3>
|
||||
<WhatsAppCampaignForm @submit="handleSubmit" @cancel="handleClose" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,357 @@
|
||||
<script setup>
|
||||
import { reactive, computed, watch, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, minLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
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';
|
||||
|
||||
const emit = defineEmits(['submit', 'cancel']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const formState = {
|
||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||
labels: useMapGetter('labels/getLabels'),
|
||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
title: '',
|
||||
inboxId: null,
|
||||
templateId: null,
|
||||
scheduledAt: null,
|
||||
selectedAudience: [],
|
||||
};
|
||||
|
||||
const state = reactive({ ...initialState });
|
||||
const processedParams = ref({});
|
||||
|
||||
const rules = {
|
||||
title: { required, minLength: minLength(1) },
|
||||
inboxId: { required },
|
||||
templateId: { required },
|
||||
scheduledAt: { required },
|
||||
selectedAudience: { required },
|
||||
};
|
||||
|
||||
const v$ = useVuelidate(rules, state);
|
||||
|
||||
const isCreating = computed(() => formState.uiFlags.value.isCreating);
|
||||
|
||||
const currentDateTime = computed(() => {
|
||||
// Added to disable the scheduled at field from being set to the current time
|
||||
const now = new Date();
|
||||
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return localTime.toISOString().slice(0, 16);
|
||||
});
|
||||
|
||||
const mapToOptions = (items, valueKey, labelKey) =>
|
||||
items?.map(item => ({
|
||||
value: item[valueKey],
|
||||
label: item[labelKey],
|
||||
})) ?? [];
|
||||
|
||||
const audienceList = computed(() =>
|
||||
mapToOptions(formState.labels.value, 'id', 'title')
|
||||
);
|
||||
|
||||
const inboxOptions = computed(() =>
|
||||
mapToOptions(formState.inboxes.value, 'id', 'name')
|
||||
);
|
||||
|
||||
const templateOptions = computed(() => {
|
||||
if (!state.inboxId) return [];
|
||||
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
||||
return templates.map(template => {
|
||||
// Create a more user-friendly label from template name
|
||||
const friendlyName = template.name
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/\b\w/g, l => l.toUpperCase());
|
||||
|
||||
return {
|
||||
value: template.id,
|
||||
label: `${friendlyName} (${template.language || 'en'})`,
|
||||
template: template,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const selectedTemplate = computed(() => {
|
||||
if (!state.templateId) return null;
|
||||
return templateOptions.value.find(option => option.value === state.templateId)
|
||||
?.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`) : '';
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
title: getErrorMessage('title', 'TITLE'),
|
||||
inbox: getErrorMessage('inboxId', 'INBOX'),
|
||||
template: getErrorMessage('templateId', 'TEMPLATE'),
|
||||
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
|
||||
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
|
||||
}));
|
||||
|
||||
const hasRequiredTemplateParams = computed(() => {
|
||||
const params = Object.values(processedParams.value);
|
||||
return params.length === 0 || params.every(param => param.trim() !== '');
|
||||
});
|
||||
|
||||
const isSubmitDisabled = computed(
|
||||
() => v$.value.$invalid || !hasRequiredTemplateParams.value
|
||||
);
|
||||
|
||||
const formatToUTCString = localDateTime =>
|
||||
localDateTime ? new Date(localDateTime).toISOString() : null;
|
||||
|
||||
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;
|
||||
|
||||
// Extract template content - this should be the template message body
|
||||
const templateContent = templateString.value;
|
||||
|
||||
// Prepare template_params object with the same structure as used in contacts
|
||||
const templateParams = {
|
||||
name: currentTemplate?.name || '',
|
||||
namespace: currentTemplate?.namespace || '',
|
||||
category: currentTemplate?.category || 'UTILITY',
|
||||
language: currentTemplate?.language || 'en_US',
|
||||
processed_params: processedParams.value,
|
||||
};
|
||||
|
||||
return {
|
||||
title: state.title,
|
||||
message: templateContent,
|
||||
template_params: templateParams,
|
||||
inbox_id: state.inboxId,
|
||||
scheduled_at: formatToUTCString(state.scheduledAt),
|
||||
audience: state.selectedAudience?.map(id => ({
|
||||
id,
|
||||
type: 'Label',
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const isFormValid = await v$.value.$validate();
|
||||
if (!isFormValid) return;
|
||||
|
||||
emit('submit', prepareCampaignDetails());
|
||||
resetState();
|
||||
handleCancel();
|
||||
};
|
||||
|
||||
// Reset template selection when inbox changes
|
||||
watch(
|
||||
() => state.inboxId,
|
||||
() => {
|
||||
state.templateId = null;
|
||||
processedParams.value = {};
|
||||
}
|
||||
);
|
||||
|
||||
// Generate variables when template changes
|
||||
watch(
|
||||
() => state.templateId,
|
||||
() => {
|
||||
generateVariables();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
|
||||
<Input
|
||||
v-model="state.title"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TITLE.PLACEHOLDER')"
|
||||
:message="formErrors.title"
|
||||
:message-type="formErrors.title ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="inbox"
|
||||
v-model="state.inboxId"
|
||||
:options="inboxOptions"
|
||||
:has-error="!!formErrors.inbox"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.INBOX.PLACEHOLDER')"
|
||||
:message="formErrors.inbox"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="template" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LABEL') }}
|
||||
</label>
|
||||
<ComboBox
|
||||
id="template"
|
||||
v-model="state.templateId"
|
||||
:options="templateOptions"
|
||||
:has-error="!!formErrors.template"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.PLACEHOLDER')"
|
||||
:message="formErrors.template"
|
||||
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-n-slate-11">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.INFO') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Template Preview -->
|
||||
<div
|
||||
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>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL') }}
|
||||
</label>
|
||||
<TagMultiSelectComboBox
|
||||
v-model="state.selectedAudience"
|
||||
:options="audienceList"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.LABEL')"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
|
||||
:has-error="!!formErrors.audience"
|
||||
:message="formErrors.audience"
|
||||
class="[&>div>button]:bg-n-alpha-black2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
v-model="state.scheduledAt"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.LABEL')"
|
||||
type="datetime-local"
|
||||
:min="currentDateTime"
|
||||
:placeholder="t('CAMPAIGN.WHATSAPP.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
|
||||
:message="formErrors.scheduledAt"
|
||||
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
|
||||
/>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
variant="faded"
|
||||
color="slate"
|
||||
type="button"
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CANCEL')"
|
||||
class="w-full bg-n-alpha-2 text-n-blue-text hover:bg-n-alpha-3"
|
||||
@click="handleCancel"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CAMPAIGN.WHATSAPP.CREATE.FORM.BUTTONS.CREATE')"
|
||||
class="w-full"
|
||||
type="submit"
|
||||
:is-loading="isCreating"
|
||||
:disabled="isCreating || isSubmitDisabled"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -331,6 +331,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
{
|
||||
name: 'WhatsApp',
|
||||
label: t('SIDEBAR.WHATSAPP'),
|
||||
to: accountScopedRoute('campaigns_whatsapp_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ export const FEATURE_FLAGS = {
|
||||
AUTO_RESOLVE_CONVERSATIONS: 'auto_resolve_conversations',
|
||||
AUTOMATIONS: 'automations',
|
||||
CAMPAIGNS: 'campaigns',
|
||||
WHATSAPP_CAMPAIGNS: 'whatsapp_campaign',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
CRM: 'crm',
|
||||
CUSTOM_ATTRIBUTES: 'custom_attributes',
|
||||
|
||||
@@ -137,6 +137,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"WHATSAPP": {
|
||||
"HEADER_TITLE": "WhatsApp campaigns",
|
||||
"NEW_CAMPAIGN": "Create campaign",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No WhatsApp campaigns are available",
|
||||
"SUBTITLE": "Launch a WhatsApp campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
|
||||
},
|
||||
"CARD": {
|
||||
"STATUS": {
|
||||
"COMPLETED": "Completed",
|
||||
"SCHEDULED": "Scheduled"
|
||||
},
|
||||
"CAMPAIGN_DETAILS": {
|
||||
"SENT_FROM": "Sent from",
|
||||
"ON": "on"
|
||||
}
|
||||
},
|
||||
"CREATE": {
|
||||
"TITLE": "Create WhatsApp campaign",
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"CREATE_BUTTON_TEXT": "Create",
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Title",
|
||||
"PLACEHOLDER": "Please enter the title of campaign",
|
||||
"ERROR": "Title is required"
|
||||
},
|
||||
"INBOX": {
|
||||
"LABEL": "Select Inbox",
|
||||
"PLACEHOLDER": "Select Inbox",
|
||||
"ERROR": "Inbox is required"
|
||||
},
|
||||
"TEMPLATE": {
|
||||
"LABEL": "WhatsApp Template",
|
||||
"PLACEHOLDER": "Select a template",
|
||||
"INFO": "Select a template to use for this campaign.",
|
||||
"ERROR": "Template is required",
|
||||
"PREVIEW_TITLE": "Process {templateName}",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"VARIABLE_PLACEHOLDER": "Enter value for {variable}"
|
||||
},
|
||||
"AUDIENCE": {
|
||||
"LABEL": "Audience",
|
||||
"PLACEHOLDER": "Select the customer labels",
|
||||
"ERROR": "Audience is required"
|
||||
},
|
||||
"SCHEDULED_AT": {
|
||||
"LABEL": "Scheduled time",
|
||||
"PLACEHOLDER": "Please select the time",
|
||||
"ERROR": "Scheduled time is required"
|
||||
},
|
||||
"BUTTONS": {
|
||||
"CREATE": "Create",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "WhatsApp campaign created successfully",
|
||||
"ERROR_MESSAGE": "There was an error. Please try again."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONFIRM_DELETE": {
|
||||
"TITLE": "Are you sure to delete?",
|
||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
||||
|
||||
@@ -319,6 +319,7 @@
|
||||
"CSAT": "CSAT",
|
||||
"LIVE_CHAT": "Live Chat",
|
||||
"SMS": "SMS",
|
||||
"WHATSAPP": "WhatsApp",
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const meta = {
|
||||
@@ -50,6 +51,15 @@ const campaignsRoutes = {
|
||||
meta,
|
||||
component: SMSCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'whatsapp',
|
||||
name: 'campaigns_whatsapp_index',
|
||||
meta: {
|
||||
...meta,
|
||||
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
|
||||
},
|
||||
component: WhatsAppCampaignsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
@@ -25,8 +24,8 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
|
||||
|
||||
const liveChatCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
|
||||
const liveChatCampaigns = computed(
|
||||
() => getters['campaigns/getLiveChatCampaigns'].value
|
||||
);
|
||||
|
||||
const hasNoLiveChatCampaigns = computed(
|
||||
@@ -59,7 +58,7 @@ const handleDelete = campaign => {
|
||||
|
||||
<div
|
||||
v-if="isFetchingCampaigns"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
class="flex justify-center items-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
@@ -23,9 +22,7 @@ const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const confirmDeleteCampaignDialogRef = ref(null);
|
||||
|
||||
const SMSCampaigns = computed(() =>
|
||||
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
|
||||
);
|
||||
const SMSCampaigns = computed(() => getters['campaigns/getSMSCampaigns'].value);
|
||||
|
||||
const hasNoSMSCampaigns = computed(
|
||||
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
|
||||
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
|
||||
import WhatsAppCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue';
|
||||
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
|
||||
import WhatsAppCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const selectedCampaign = ref(null);
|
||||
const [showWhatsAppCampaignDialog, toggleWhatsAppCampaignDialog] = useToggle();
|
||||
|
||||
const uiFlags = useMapGetter('campaigns/getUIFlags');
|
||||
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
const confirmDeleteCampaignDialogRef = ref(null);
|
||||
|
||||
const WhatsAppCampaigns = computed(
|
||||
() => getters['campaigns/getWhatsAppCampaigns'].value
|
||||
);
|
||||
|
||||
const hasNoWhatsAppCampaigns = computed(
|
||||
() => WhatsAppCampaigns.value?.length === 0 && !isFetchingCampaigns.value
|
||||
);
|
||||
|
||||
const handleDelete = campaign => {
|
||||
selectedCampaign.value = campaign;
|
||||
confirmDeleteCampaignDialogRef.value.dialogRef.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CampaignLayout
|
||||
:header-title="t('CAMPAIGN.WHATSAPP.HEADER_TITLE')"
|
||||
:button-label="t('CAMPAIGN.WHATSAPP.NEW_CAMPAIGN')"
|
||||
@click="toggleWhatsAppCampaignDialog()"
|
||||
@close="toggleWhatsAppCampaignDialog(false)"
|
||||
>
|
||||
<template #action>
|
||||
<WhatsAppCampaignDialog
|
||||
v-if="showWhatsAppCampaignDialog"
|
||||
@close="toggleWhatsAppCampaignDialog(false)"
|
||||
/>
|
||||
</template>
|
||||
<div
|
||||
v-if="isFetchingCampaigns"
|
||||
class="flex items-center justify-center py-10 text-n-slate-11"
|
||||
>
|
||||
<Spinner />
|
||||
</div>
|
||||
<CampaignList
|
||||
v-else-if="!hasNoWhatsAppCampaigns"
|
||||
:campaigns="WhatsAppCampaigns"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
<WhatsAppCampaignEmptyState
|
||||
v-else
|
||||
:title="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.TITLE')"
|
||||
:subtitle="t('CAMPAIGN.WHATSAPP.EMPTY_STATE.SUBTITLE')"
|
||||
class="pt-14"
|
||||
/>
|
||||
<ConfirmDeleteCampaignDialog
|
||||
ref="confirmDeleteCampaignDialogRef"
|
||||
:selected-campaign="selectedCampaign"
|
||||
/>
|
||||
</CampaignLayout>
|
||||
</template>
|
||||
@@ -3,6 +3,8 @@ import types from '../mutation-types';
|
||||
import CampaignsAPI from '../../api/campaigns';
|
||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||
import { CAMPAIGNS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||
import { CAMPAIGN_TYPES } from 'shared/constants/campaign';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export const state = {
|
||||
records: [],
|
||||
@@ -16,10 +18,35 @@ export const getters = {
|
||||
getUIFlags(_state) {
|
||||
return _state.uiFlags;
|
||||
},
|
||||
getCampaigns: _state => campaignType => {
|
||||
return _state.records
|
||||
.filter(record => record.campaign_type === campaignType)
|
||||
.sort((a1, a2) => a1.id - a2.id);
|
||||
getCampaigns:
|
||||
_state =>
|
||||
(campaignType, inboxChannelTypes = null) => {
|
||||
let filteredRecords = _state.records.filter(
|
||||
record => record.campaign_type === campaignType
|
||||
);
|
||||
|
||||
if (inboxChannelTypes && Array.isArray(inboxChannelTypes)) {
|
||||
filteredRecords = filteredRecords.filter(record => {
|
||||
return (
|
||||
record.inbox &&
|
||||
inboxChannelTypes.includes(record.inbox.channel_type)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return filteredRecords.sort((a1, a2) => a1.id - a2.id);
|
||||
},
|
||||
getSMSCampaigns: (_state, _getters) => {
|
||||
const smsChannelTypes = [INBOX_TYPES.SMS, INBOX_TYPES.TWILIO];
|
||||
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, smsChannelTypes);
|
||||
},
|
||||
getWhatsAppCampaigns: (_state, _getters) => {
|
||||
const whatsappChannelTypes = [INBOX_TYPES.WHATSAPP];
|
||||
return _getters.getCampaigns(CAMPAIGN_TYPES.ONE_OFF, whatsappChannelTypes);
|
||||
},
|
||||
getLiveChatCampaigns: (_state, _getters) => {
|
||||
const liveChatChannelTypes = [INBOX_TYPES.WEB];
|
||||
return _getters.getCampaigns(CAMPAIGN_TYPES.ONGOING, liveChatChannelTypes);
|
||||
},
|
||||
getAllCampaigns: _state => {
|
||||
return _state.records;
|
||||
|
||||
@@ -96,6 +96,11 @@ export const getters = {
|
||||
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
||||
);
|
||||
},
|
||||
getWhatsAppInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type === INBOX_TYPES.WHATSAPP
|
||||
);
|
||||
},
|
||||
dialogFlowEnabledInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
|
||||
@@ -11,6 +11,11 @@ export default [
|
||||
url: 'https://github.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
inbox: {
|
||||
id: 1,
|
||||
channel_type: 'Channel::WebWidget',
|
||||
name: 'Web Widget',
|
||||
},
|
||||
created_at: '2021-05-03T04:53:36.354Z',
|
||||
updated_at: '2021-05-03T04:53:36.354Z',
|
||||
},
|
||||
@@ -24,6 +29,11 @@ export default [
|
||||
url: 'https://chatwoot.com',
|
||||
time_on_page: '20',
|
||||
},
|
||||
inbox: {
|
||||
id: 2,
|
||||
channel_type: 'Channel::TwilioSms',
|
||||
name: 'Twilio SMS',
|
||||
},
|
||||
created_at: '2021-05-03T08:15:35.828Z',
|
||||
updated_at: '2021-05-03T08:15:35.828Z',
|
||||
},
|
||||
@@ -39,7 +49,52 @@ export default [
|
||||
url: 'https://noshow.com',
|
||||
time_on_page: 10,
|
||||
},
|
||||
inbox: {
|
||||
id: 3,
|
||||
channel_type: 'Channel::WebWidget',
|
||||
name: 'Web Widget 2',
|
||||
},
|
||||
created_at: '2021-05-03T10:22:51.025Z',
|
||||
updated_at: '2021-05-03T10:22:51.025Z',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'WhatsApp Campaign',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
campaign_type: 'one_off',
|
||||
message: 'Hello {{name}}, your order is ready!',
|
||||
enabled: true,
|
||||
trigger_rules: {},
|
||||
inbox: {
|
||||
id: 4,
|
||||
channel_type: 'Channel::Whatsapp',
|
||||
name: 'WhatsApp Business',
|
||||
},
|
||||
template_params: {
|
||||
name: 'order_ready',
|
||||
namespace: 'business_namespace',
|
||||
language: 'en_US',
|
||||
processed_params: { name: 'John' },
|
||||
},
|
||||
created_at: '2021-05-03T12:15:35.828Z',
|
||||
updated_at: '2021-05-03T12:15:35.828Z',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'SMS Promotion',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
campaign_type: 'one_off',
|
||||
message: 'Get 20% off your next order!',
|
||||
enabled: true,
|
||||
trigger_rules: {},
|
||||
inbox: {
|
||||
id: 5,
|
||||
channel_type: 'Channel::Sms',
|
||||
name: 'SMS Channel',
|
||||
},
|
||||
created_at: '2021-05-03T14:15:35.828Z',
|
||||
updated_at: '2021-05-03T14:15:35.828Z',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -13,20 +13,58 @@ describe('#getters', () => {
|
||||
it('get one_off campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
expect(getters.getCampaigns(state)('one_off')).toEqual([
|
||||
{
|
||||
id: 2,
|
||||
title: 'Onboarding Campaign',
|
||||
description: null,
|
||||
account_id: 1,
|
||||
campaign_type: 'one_off',
|
||||
campaigns[1],
|
||||
campaigns[3],
|
||||
campaigns[4],
|
||||
]);
|
||||
});
|
||||
|
||||
trigger_rules: {
|
||||
url: 'https://chatwoot.com',
|
||||
time_on_page: '20',
|
||||
},
|
||||
created_at: '2021-05-03T08:15:35.828Z',
|
||||
updated_at: '2021-05-03T08:15:35.828Z',
|
||||
},
|
||||
it('get campaigns by channel type', () => {
|
||||
const state = { records: campaigns };
|
||||
expect(
|
||||
getters.getCampaigns(state)('one_off', ['Channel::Whatsapp'])
|
||||
).toEqual([campaigns[3]]);
|
||||
});
|
||||
|
||||
it('get campaigns by multiple channel types', () => {
|
||||
const state = { records: campaigns };
|
||||
expect(
|
||||
getters.getCampaigns(state)('one_off', [
|
||||
'Channel::TwilioSms',
|
||||
'Channel::Sms',
|
||||
])
|
||||
).toEqual([campaigns[1], campaigns[4]]);
|
||||
});
|
||||
|
||||
it('get SMS campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
const mockGetters = {
|
||||
getCampaigns: getters.getCampaigns(state),
|
||||
};
|
||||
expect(getters.getSMSCampaigns(state, mockGetters)).toEqual([
|
||||
campaigns[1],
|
||||
campaigns[4],
|
||||
]);
|
||||
});
|
||||
|
||||
it('get WhatsApp campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
const mockGetters = {
|
||||
getCampaigns: getters.getCampaigns(state),
|
||||
};
|
||||
expect(getters.getWhatsAppCampaigns(state, mockGetters)).toEqual([
|
||||
campaigns[3],
|
||||
]);
|
||||
});
|
||||
|
||||
it('get Live Chat campaigns', () => {
|
||||
const state = { records: campaigns };
|
||||
const mockGetters = {
|
||||
getCampaigns: getters.getCampaigns(state),
|
||||
};
|
||||
expect(getters.getLiveChatCampaigns(state, mockGetters)).toEqual([
|
||||
campaigns[0],
|
||||
campaigns[2],
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
# enabled :boolean default(TRUE)
|
||||
# message :text not null
|
||||
# scheduled_at :datetime
|
||||
# template_params :jsonb
|
||||
# title :string not null
|
||||
# trigger_only_during_business_hours :boolean default(FALSE)
|
||||
# trigger_rules :jsonb
|
||||
@@ -57,12 +58,22 @@ class Campaign < ApplicationRecord
|
||||
return unless one_off?
|
||||
return if completed?
|
||||
|
||||
Twilio::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Twilio SMS'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform if inbox.inbox_type == 'Sms'
|
||||
execute_campaign
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def execute_campaign
|
||||
case inbox.inbox_type
|
||||
when 'Twilio SMS'
|
||||
Twilio::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Sms'
|
||||
Sms::OneoffSmsCampaignService.new(campaign: self).perform
|
||||
when 'Whatsapp'
|
||||
Whatsapp::OneoffCampaignService.new(campaign: self).perform if account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
end
|
||||
|
||||
def set_display_id
|
||||
reload
|
||||
end
|
||||
@@ -70,14 +81,14 @@ class Campaign < ApplicationRecord
|
||||
def validate_campaign_inbox
|
||||
return unless inbox
|
||||
|
||||
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms'].include? inbox.inbox_type
|
||||
errors.add :inbox, 'Unsupported Inbox type' unless ['Website', 'Twilio SMS', 'Sms', 'Whatsapp'].include? inbox.inbox_type
|
||||
end
|
||||
|
||||
# TO-DO we clean up with better validations when campaigns evolve into more inboxes
|
||||
def ensure_correct_campaign_attributes
|
||||
return if inbox.blank?
|
||||
|
||||
if ['Twilio SMS', 'Sms'].include?(inbox.inbox_type)
|
||||
if ['Twilio SMS', 'Sms', 'Whatsapp'].include?(inbox.inbox_type)
|
||||
self.campaign_type = 'one_off'
|
||||
self.scheduled_at ||= Time.now.utc
|
||||
else
|
||||
|
||||
94
app/services/whatsapp/oneoff_campaign_service.rb
Normal file
94
app/services/whatsapp/oneoff_campaign_service.rb
Normal file
@@ -0,0 +1,94 @@
|
||||
class Whatsapp::OneoffCampaignService
|
||||
pattr_initialize [:campaign!]
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
process_audience(extract_audience_labels)
|
||||
campaign.completed!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, to: :campaign
|
||||
delegate :channel, to: :inbox
|
||||
|
||||
def validate_campaign_type!
|
||||
raise "Invalid campaign #{campaign.id}" unless whatsapp_campaign? && campaign.one_off?
|
||||
end
|
||||
|
||||
def whatsapp_campaign?
|
||||
campaign.inbox.inbox_type == 'Whatsapp'
|
||||
end
|
||||
|
||||
def validate_campaign_status!
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
end
|
||||
|
||||
def validate_provider!
|
||||
raise 'WhatsApp Cloud provider required' if channel.provider != 'whatsapp_cloud'
|
||||
end
|
||||
|
||||
def validate_feature_flag!
|
||||
raise 'WhatsApp campaigns feature not enabled' unless campaign.account.feature_enabled?(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
def validate_campaign!
|
||||
validate_campaign_type!
|
||||
validate_campaign_status!
|
||||
validate_provider!
|
||||
validate_feature_flag!
|
||||
end
|
||||
|
||||
def extract_audience_labels
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
end
|
||||
|
||||
def process_contact(contact)
|
||||
Rails.logger.info "Processing contact: #{contact.name} (#{contact.phone_number})"
|
||||
|
||||
if contact.phone_number.blank?
|
||||
Rails.logger.info "Skipping contact #{contact.name} - no phone number"
|
||||
return
|
||||
end
|
||||
|
||||
if campaign.template_params.blank?
|
||||
Rails.logger.error "Skipping contact #{contact.name} - no template_params found for WhatsApp campaign"
|
||||
return
|
||||
end
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number)
|
||||
end
|
||||
|
||||
def process_audience(audience_labels)
|
||||
contacts = campaign.account.contacts.tagged_with(audience_labels, any: true)
|
||||
Rails.logger.info "Processing #{contacts.count} contacts for campaign #{campaign.id}"
|
||||
|
||||
contacts.each { |contact| process_contact(contact) }
|
||||
|
||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:)
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: campaign.template_params
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
return if name.blank?
|
||||
|
||||
channel.send_template(to, {
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
lang_code: lang_code,
|
||||
parameters: processed_parameters
|
||||
})
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{e.message}"
|
||||
Rails.logger.error "Backtrace: #{e.backtrace.first(5).join('\n')}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
@@ -15,7 +15,13 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
end
|
||||
|
||||
def send_template_message
|
||||
name, namespace, lang_code, processed_parameters = processable_channel_message_template
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: template_params,
|
||||
message: message
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
return if name.blank?
|
||||
|
||||
@@ -28,86 +34,6 @@ class Whatsapp::SendOnWhatsappService < Base::SendOnChannelService
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
end
|
||||
|
||||
def processable_channel_message_template
|
||||
if template_params.present?
|
||||
return [
|
||||
template_params['name'],
|
||||
template_params['namespace'],
|
||||
template_params['language'],
|
||||
processed_templates_params(template_params)
|
||||
]
|
||||
end
|
||||
|
||||
# Delete the following logic once the update for template_params is stable
|
||||
# see if we can match the message content to a template
|
||||
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
|
||||
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
|
||||
# Then we use regex to parse the template varibles and convert them into the proper payload
|
||||
channel.message_templates&.each do |template|
|
||||
match_obj = template_match_object(template)
|
||||
next if match_obj.blank?
|
||||
|
||||
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
|
||||
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
|
||||
|
||||
# no need to look up further end the search
|
||||
return [template['name'], template['namespace'], template['language'], processed_parameters]
|
||||
end
|
||||
[nil, nil, nil, nil]
|
||||
end
|
||||
|
||||
def template_match_object(template)
|
||||
body_object = validated_body_object(template)
|
||||
return if body_object.blank?
|
||||
|
||||
template_match_regex = build_template_match_regex(body_object['text'])
|
||||
message.outgoing_content.match(template_match_regex)
|
||||
end
|
||||
|
||||
def build_template_match_regex(template_text)
|
||||
# Converts the whatsapp template to a comparable regex string to check against the message content
|
||||
# the variables are of the format {{num}} ex:{{1}}
|
||||
|
||||
# transform the template text into a regex string
|
||||
# we need to replace the {{num}} with matchers that can be used to capture the variables
|
||||
template_text = template_text.gsub(/{{\d}}/, '(.*)')
|
||||
# escape if there are regex characters in the template text
|
||||
template_text = Regexp.escape(template_text)
|
||||
# ensuring only the variables remain as capture groups
|
||||
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
|
||||
|
||||
template_match_string = "^#{template_text}$"
|
||||
Regexp.new template_match_string
|
||||
end
|
||||
|
||||
def template(template_params)
|
||||
channel.message_templates.find do |t|
|
||||
t['name'] == template_params['name'] && t['language'] == template_params['language']
|
||||
end
|
||||
end
|
||||
|
||||
def processed_templates_params(template_params)
|
||||
template = template(template_params)
|
||||
return if template.blank?
|
||||
|
||||
parameter_format = template['parameter_format']
|
||||
|
||||
if parameter_format == 'NAMED'
|
||||
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
|
||||
else
|
||||
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
|
||||
end
|
||||
end
|
||||
|
||||
def validated_body_object(template)
|
||||
# we don't care if its not approved template
|
||||
return if template['status'] != 'approved'
|
||||
|
||||
# we only care about text body object in template. if not present we discard the template
|
||||
# we don't support other forms of templates
|
||||
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
|
||||
end
|
||||
|
||||
def send_session_message
|
||||
message_id = channel.send_message(message.conversation.contact_inbox.source_id, message)
|
||||
message.update!(source_id: message_id) if message_id.present?
|
||||
|
||||
95
app/services/whatsapp/template_processor_service.rb
Normal file
95
app/services/whatsapp/template_processor_service.rb
Normal file
@@ -0,0 +1,95 @@
|
||||
class Whatsapp::TemplateProcessorService
|
||||
pattr_initialize [:channel!, :template_params, :message]
|
||||
|
||||
def call
|
||||
if template_params.present?
|
||||
process_template_with_params
|
||||
else
|
||||
process_template_from_message
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_template_with_params
|
||||
[
|
||||
template_params['name'],
|
||||
template_params['namespace'],
|
||||
template_params['language'],
|
||||
processed_templates_params
|
||||
]
|
||||
end
|
||||
|
||||
def process_template_from_message
|
||||
return [nil, nil, nil, nil] if message.blank?
|
||||
|
||||
# Delete the following logic once the update for template_params is stable
|
||||
# see if we can match the message content to a template
|
||||
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
|
||||
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
|
||||
# Then we use regex to parse the template varibles and convert them into the proper payload
|
||||
channel.message_templates&.each do |template|
|
||||
match_obj = template_match_object(template)
|
||||
next if match_obj.blank?
|
||||
|
||||
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
|
||||
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
|
||||
|
||||
# no need to look up further end the search
|
||||
return [template['name'], template['namespace'], template['language'], processed_parameters]
|
||||
end
|
||||
[nil, nil, nil, nil]
|
||||
end
|
||||
|
||||
def template_match_object(template)
|
||||
body_object = validated_body_object(template)
|
||||
return if body_object.blank?
|
||||
|
||||
template_match_regex = build_template_match_regex(body_object['text'])
|
||||
message.outgoing_content.match(template_match_regex)
|
||||
end
|
||||
|
||||
def build_template_match_regex(template_text)
|
||||
# Converts the whatsapp template to a comparable regex string to check against the message content
|
||||
# the variables are of the format {{num}} ex:{{1}}
|
||||
|
||||
# transform the template text into a regex string
|
||||
# we need to replace the {{num}} with matchers that can be used to capture the variables
|
||||
template_text = template_text.gsub(/{{\d}}/, '(.*)')
|
||||
# escape if there are regex characters in the template text
|
||||
template_text = Regexp.escape(template_text)
|
||||
# ensuring only the variables remain as capture groups
|
||||
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
|
||||
|
||||
template_match_string = "^#{template_text}$"
|
||||
Regexp.new template_match_string
|
||||
end
|
||||
|
||||
def find_template
|
||||
channel.message_templates.find do |t|
|
||||
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
|
||||
end
|
||||
end
|
||||
|
||||
def processed_templates_params
|
||||
template = find_template
|
||||
return if template.blank?
|
||||
|
||||
parameter_format = template['parameter_format']
|
||||
|
||||
if parameter_format == 'NAMED'
|
||||
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
|
||||
else
|
||||
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
|
||||
end
|
||||
end
|
||||
|
||||
def validated_body_object(template)
|
||||
# we don't care if its not approved template
|
||||
return if template['status'] != 'approved'
|
||||
|
||||
# we only care about text body object in template. if not present we discard the template
|
||||
# we don't support other forms of templates
|
||||
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ json.sender do
|
||||
json.partial! 'api/v1/models/agent', formats: [:json], resource: resource.sender if resource.sender.present?
|
||||
end
|
||||
json.message resource.message
|
||||
json.template_params resource.template_params
|
||||
json.campaign_status resource.campaign_status
|
||||
json.enabled resource.enabled
|
||||
json.campaign_type resource.campaign_type
|
||||
|
||||
@@ -183,3 +183,6 @@
|
||||
- name: whatsapp_embedded_signup
|
||||
display_name: WhatsApp Embedded Signup
|
||||
enabled: false
|
||||
- name: whatsapp_campaign
|
||||
display_name: WhatsApp Campaign
|
||||
enabled: false
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :campaigns, :template_params, :jsonb, default: {}, null: false
|
||||
end
|
||||
end
|
||||
@@ -237,6 +237,7 @@ ActiveRecord::Schema[7.1].define(version: 2025_07_14_104358) do
|
||||
t.jsonb "audience", default: []
|
||||
t.datetime "scheduled_at", precision: nil
|
||||
t.boolean "trigger_only_during_business_hours", default: false
|
||||
t.jsonb "template_params"
|
||||
t.index ["account_id"], name: "index_campaigns_on_account_id"
|
||||
t.index ["campaign_status"], name: "index_campaigns_on_campaign_status"
|
||||
t.index ["campaign_type"], name: "index_campaigns_on_campaign_type"
|
||||
|
||||
@@ -12,5 +12,22 @@ FactoryBot.define do
|
||||
channel: create(:channel_widget, account: campaign.account)
|
||||
)
|
||||
end
|
||||
|
||||
trait :whatsapp do
|
||||
after(:build) do |campaign|
|
||||
campaign.inbox = create(
|
||||
:inbox,
|
||||
account: campaign.account,
|
||||
channel: create(:channel_whatsapp, account: campaign.account)
|
||||
)
|
||||
campaign.template_params = {
|
||||
'name' => 'ticket_status_updated',
|
||||
'namespace' => '23423423_2342423_324234234_2343224',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -36,6 +36,7 @@ FactoryBot.define do
|
||||
'status' => 'APPROVED',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'namespace' => '23423423_2342423_324234234_2343224',
|
||||
'components' => [
|
||||
{ 'text' => "Hello {{name}}, Your support ticket with ID: \#{{ticket_id}} has been updated by the support agent.",
|
||||
'type' => 'BODY',
|
||||
|
||||
169
spec/services/whatsapp/oneoff_campaign_service_spec.rb
Normal file
169
spec/services/whatsapp/oneoff_campaign_service_spec.rb
Normal file
@@ -0,0 +1,169 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::OneoffCampaignService do
|
||||
let(:account) { create(:account) }
|
||||
let!(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, provider: 'whatsapp_cloud', validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let!(:whatsapp_inbox) { whatsapp_channel.inbox }
|
||||
let(:label1) { create(:label, account: account) }
|
||||
let(:label2) { create(:label, account: account) }
|
||||
let!(:campaign) do
|
||||
create(:campaign, inbox: whatsapp_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }, { type: 'Label', id: label2.id }],
|
||||
template_params: template_params)
|
||||
end
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'ticket_status_updated',
|
||||
'namespace' => '23423423_2342423_324234234_2343224',
|
||||
'category' => 'UTILITY',
|
||||
'language' => 'en',
|
||||
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
# Stub HTTP requests to WhatsApp API
|
||||
stub_request(:post, /graph\.facebook\.com.*messages/)
|
||||
.to_return(status: 200, body: { messages: [{ id: 'message_id_123' }] }.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
# Ensure the service uses our mocked channel object by stubbing the whole delegation chain
|
||||
# Using allow_any_instance_of here because the service is instantiated within individual tests
|
||||
# and we need to mock the delegated channel method for proper test isolation
|
||||
allow_any_instance_of(described_class).to receive(:channel).and_return(whatsapp_channel) # rubocop:disable RSpec/AnyInstance
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
# Enable WhatsApp campaigns feature flag for all tests
|
||||
account.enable_features!(:whatsapp_campaign)
|
||||
end
|
||||
|
||||
context 'when campaign validation fails' do
|
||||
it 'raises error if campaign is completed' do
|
||||
campaign.completed!
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error 'Completed Campaign'
|
||||
end
|
||||
|
||||
it 'raises error when campaign is not a WhatsApp campaign' do
|
||||
sms_channel = create(:channel_sms, account: account)
|
||||
sms_inbox = create(:inbox, channel: sms_channel, account: account)
|
||||
invalid_campaign = create(:campaign, inbox: sms_inbox, account: account)
|
||||
|
||||
expect { described_class.new(campaign: invalid_campaign).perform }
|
||||
.to raise_error "Invalid campaign #{invalid_campaign.id}"
|
||||
end
|
||||
|
||||
it 'raises error when campaign is not oneoff' do
|
||||
allow(campaign).to receive(:one_off?).and_return(false)
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error "Invalid campaign #{campaign.id}"
|
||||
end
|
||||
|
||||
it 'raises error when channel provider is not whatsapp_cloud' do
|
||||
whatsapp_channel.update!(provider: 'default')
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp Cloud provider required'
|
||||
end
|
||||
|
||||
it 'raises error when WhatsApp campaigns feature is not enabled' do
|
||||
account.disable_features!(:whatsapp_campaign)
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error 'WhatsApp campaigns feature not enabled'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when campaign is valid' do
|
||||
it 'marks campaign as completed' do
|
||||
described_class.new(campaign: campaign).perform
|
||||
|
||||
expect(campaign.reload.completed?).to be true
|
||||
end
|
||||
|
||||
it 'processes contacts with matching labels' do
|
||||
contact_with_label1, contact_with_label2, contact_with_both_labels =
|
||||
create_list(:contact, 3, :with_phone_number, account: account)
|
||||
contact_with_label1.update_labels([label1.title])
|
||||
contact_with_label2.update_labels([label2.title])
|
||||
contact_with_both_labels.update_labels([label1.title, label2.title])
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template).exactly(3).times
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
|
||||
it 'skips contacts without phone numbers' do
|
||||
contact_without_phone = create(:contact, account: account, phone_number: nil)
|
||||
contact_without_phone.update_labels([label1.title])
|
||||
|
||||
expect(whatsapp_channel).not_to receive(:send_template)
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
|
||||
it 'uses template processor service to process templates' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(Whatsapp::TemplateProcessorService).to receive(:new)
|
||||
.with(channel: whatsapp_channel, template_params: template_params)
|
||||
.and_call_original
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
|
||||
it 'sends template message with correct parameters' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(whatsapp_channel).to receive(:send_template).with(
|
||||
contact.phone_number,
|
||||
hash_including(
|
||||
name: 'ticket_status_updated',
|
||||
namespace: '23423423_2342423_324234234_2343224',
|
||||
lang_code: 'en',
|
||||
parameters: array_including(
|
||||
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
|
||||
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template_params is missing' do
|
||||
let(:template_params) { nil }
|
||||
|
||||
it 'skips contacts and logs error' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
expect(Rails.logger).to receive(:error)
|
||||
.with("Skipping contact #{contact.name} - no template_params found for WhatsApp campaign")
|
||||
expect(whatsapp_channel).not_to receive(:send_template)
|
||||
|
||||
described_class.new(campaign: campaign).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when send_template raises an error' do
|
||||
it 'logs error and re-raises' do
|
||||
contact = create(:contact, :with_phone_number, account: account)
|
||||
contact.update_labels([label1.title])
|
||||
error_message = 'WhatsApp API error'
|
||||
|
||||
allow(whatsapp_channel).to receive(:send_template).and_raise(StandardError, error_message)
|
||||
|
||||
expect(Rails.logger).to receive(:error)
|
||||
.with("Failed to send WhatsApp template message to #{contact.phone_number}: #{error_message}")
|
||||
expect(Rails.logger).to receive(:error).with(/Backtrace:/)
|
||||
|
||||
expect { described_class.new(campaign: campaign).perform }.to raise_error(StandardError, error_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user