From 0ea616a6eaeb0398b474d46192d142bf43c3d1f0 Mon Sep 17 00:00:00 2001 From: Aman Kumar <72304680+Aman-14@users.noreply.github.com> Date: Wed, 16 Jul 2025 09:04:02 +0530 Subject: [PATCH] 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 Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../api/v1/accounts/campaigns_controller.rb | 2 +- .../EmptyState/WhatsAppCampaignEmptyState.vue | 37 ++ .../WhatsAppCampaignDialog.vue | 48 +++ .../WhatsAppCampaign/WhatsAppCampaignForm.vue | 357 ++++++++++++++++++ .../components-next/sidebar/Sidebar.vue | 5 + app/javascript/dashboard/featureFlags.js | 1 + .../dashboard/i18n/locale/en/campaign.json | 64 ++++ .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/campaigns/campaigns.routes.js | 10 + .../campaigns/pages/LiveChatCampaignsPage.vue | 7 +- .../campaigns/pages/SMSCampaignsPage.vue | 5 +- .../campaigns/pages/WhatsAppCampaignsPage.vue | 74 ++++ .../dashboard/store/modules/campaigns.js | 35 +- .../dashboard/store/modules/inboxes.js | 5 + .../store/modules/specs/campaigns/fixtures.js | 55 +++ .../modules/specs/campaigns/getters.spec.js | 64 +++- app/models/campaign.rb | 19 +- .../whatsapp/oneoff_campaign_service.rb | 94 +++++ .../whatsapp/send_on_whatsapp_service.rb | 88 +---- .../whatsapp/template_processor_service.rb | 95 +++++ .../api/v1/models/_campaign.json.jbuilder | 1 + config/features.yml | 3 + ...102213_add_template_params_to_campaigns.rb | 5 + db/schema.rb | 1 + spec/factories/campaigns.rb | 17 + spec/factories/channel/channel_whatsapp.rb | 1 + .../whatsapp/oneoff_campaign_service_spec.rb | 169 +++++++++ 27 files changed, 1152 insertions(+), 111 deletions(-) create mode 100644 app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue create mode 100644 app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue create mode 100644 app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue create mode 100644 app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue create mode 100644 app/services/whatsapp/oneoff_campaign_service.rb create mode 100644 app/services/whatsapp/template_processor_service.rb create mode 100644 db/migrate/20250709102213_add_template_params_to_campaigns.rb create mode 100644 spec/services/whatsapp/oneoff_campaign_service_spec.rb diff --git a/app/controllers/api/v1/accounts/campaigns_controller.rb b/app/controllers/api/v1/accounts/campaigns_controller.rb index b1a132246..2a1650e53 100644 --- a/app/controllers/api/v1/accounts/campaigns_controller.rb +++ b/app/controllers/api/v1/accounts/campaigns_controller.rb @@ -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 diff --git a/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue new file mode 100644 index 000000000..ab01acb4a --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/EmptyState/WhatsAppCampaignEmptyState.vue @@ -0,0 +1,37 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue new file mode 100644 index 000000000..12a789fee --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignDialog.vue @@ -0,0 +1,48 @@ + + + diff --git a/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue new file mode 100644 index 000000000..df76ae901 --- /dev/null +++ b/app/javascript/dashboard/components-next/Campaigns/Pages/CampaignPage/WhatsAppCampaign/WhatsAppCampaignForm.vue @@ -0,0 +1,357 @@ + + + diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 171f4a4d8..12f15ce4b 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -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'), + }, ], }, { diff --git a/app/javascript/dashboard/featureFlags.js b/app/javascript/dashboard/featureFlags.js index 78109e82c..28b6b09b7 100644 --- a/app/javascript/dashboard/featureFlags.js +++ b/app/javascript/dashboard/featureFlags.js @@ -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', diff --git a/app/javascript/dashboard/i18n/locale/en/campaign.json b/app/javascript/dashboard/i18n/locale/en/campaign.json index e2418d52e..10366e79e 100644 --- a/app/javascript/dashboard/i18n/locale/en/campaign.json +++ b/app/javascript/dashboard/i18n/locale/en/campaign.json @@ -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.", diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 219041d75..fde198c92 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -319,6 +319,7 @@ "CSAT": "CSAT", "LIVE_CHAT": "Live Chat", "SMS": "SMS", + "WHATSAPP": "WhatsApp", "CAMPAIGNS": "Campaigns", "ONGOING": "Ongoing", "ONE_OFF": "One off", diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js index e0c1f3a17..c5f2dbdb1 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js +++ b/app/javascript/dashboard/routes/dashboard/campaigns/campaigns.routes.js @@ -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, + }, ], }, ], diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue index 93d5d1ec6..6cc2bf181 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/LiveChatCampaignsPage.vue @@ -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 => {
diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue index a38a818f0..c04726ebe 100644 --- a/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/SMSCampaignsPage.vue @@ -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 diff --git a/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue new file mode 100644 index 000000000..96aa21b5a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/campaigns/pages/WhatsAppCampaignsPage.vue @@ -0,0 +1,74 @@ + + + diff --git a/app/javascript/dashboard/store/modules/campaigns.js b/app/javascript/dashboard/store/modules/campaigns.js index c5a73d1d3..c558716f0 100644 --- a/app/javascript/dashboard/store/modules/campaigns.js +++ b/app/javascript/dashboard/store/modules/campaigns.js @@ -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; diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 68f4780e9..14bd4c2a9 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -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 diff --git a/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js b/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js index 08fd398e2..400b43638 100644 --- a/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js +++ b/app/javascript/dashboard/store/modules/specs/campaigns/fixtures.js @@ -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', + }, ]; diff --git a/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js b/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js index 52f14c296..231b83adc 100644 --- a/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js +++ b/app/javascript/dashboard/store/modules/specs/campaigns/getters.spec.js @@ -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], ]); }); diff --git a/app/models/campaign.rb b/app/models/campaign.rb index f937b1d71..2927181c6 100644 --- a/app/models/campaign.rb +++ b/app/models/campaign.rb @@ -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 diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb new file mode 100644 index 000000000..c2f0080f3 --- /dev/null +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -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 diff --git a/app/services/whatsapp/send_on_whatsapp_service.rb b/app/services/whatsapp/send_on_whatsapp_service.rb index 61b78f892..8cecfd41f 100644 --- a/app/services/whatsapp/send_on_whatsapp_service.rb +++ b/app/services/whatsapp/send_on_whatsapp_service.rb @@ -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? diff --git a/app/services/whatsapp/template_processor_service.rb b/app/services/whatsapp/template_processor_service.rb new file mode 100644 index 000000000..2ce9fcf8f --- /dev/null +++ b/app/services/whatsapp/template_processor_service.rb @@ -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 diff --git a/app/views/api/v1/models/_campaign.json.jbuilder b/app/views/api/v1/models/_campaign.json.jbuilder index 8706175df..8757fd0f8 100644 --- a/app/views/api/v1/models/_campaign.json.jbuilder +++ b/app/views/api/v1/models/_campaign.json.jbuilder @@ -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 diff --git a/config/features.yml b/config/features.yml index 99d7bea6d..85b95a732 100644 --- a/config/features.yml +++ b/config/features.yml @@ -183,3 +183,6 @@ - name: whatsapp_embedded_signup display_name: WhatsApp Embedded Signup enabled: false +- name: whatsapp_campaign + display_name: WhatsApp Campaign + enabled: false diff --git a/db/migrate/20250709102213_add_template_params_to_campaigns.rb b/db/migrate/20250709102213_add_template_params_to_campaigns.rb new file mode 100644 index 000000000..d70359b30 --- /dev/null +++ b/db/migrate/20250709102213_add_template_params_to_campaigns.rb @@ -0,0 +1,5 @@ +class AddTemplateParamsToCampaigns < ActiveRecord::Migration[7.1] + def change + add_column :campaigns, :template_params, :jsonb, default: {}, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 1806ab23f..34637315b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/spec/factories/campaigns.rb b/spec/factories/campaigns.rb index 4d4c6de18..c3c32d149 100644 --- a/spec/factories/campaigns.rb +++ b/spec/factories/campaigns.rb @@ -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 diff --git a/spec/factories/channel/channel_whatsapp.rb b/spec/factories/channel/channel_whatsapp.rb index d437ed346..ad2bab241 100644 --- a/spec/factories/channel/channel_whatsapp.rb +++ b/spec/factories/channel/channel_whatsapp.rb @@ -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', diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb new file mode 100644 index 000000000..33107e8de --- /dev/null +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -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