From c835308687ac69b53d0e9c6d89dcc4fc8b91af38 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 19 Aug 2025 18:40:56 +0530 Subject: [PATCH] feat: Add liquid template support for WhatsApp campaigns --- .../campaign_liquid_processor_service.rb | 54 +++++ .../whatsapp/oneoff_campaign_service.rb | 43 ++-- .../campaign_liquid_processor_service_spec.rb | 193 ++++++++++++++++++ .../whatsapp/oneoff_campaign_service_spec.rb | 47 +++++ 4 files changed, 325 insertions(+), 12 deletions(-) create mode 100644 app/services/whatsapp/campaign_liquid_processor_service.rb create mode 100644 spec/services/whatsapp/campaign_liquid_processor_service_spec.rb diff --git a/app/services/whatsapp/campaign_liquid_processor_service.rb b/app/services/whatsapp/campaign_liquid_processor_service.rb new file mode 100644 index 000000000..7e2ebd8cc --- /dev/null +++ b/app/services/whatsapp/campaign_liquid_processor_service.rb @@ -0,0 +1,54 @@ +class Whatsapp::CampaignLiquidProcessorService + pattr_initialize [:campaign!, :contact!] + + def call(template_params) + return template_params if template_params.blank? || template_params['processed_params'].blank? + + processed_template_params = template_params.deep_dup + processed_template_params['processed_params'] = process_liquid_in_hash(template_params['processed_params']) + processed_template_params + end + + private + + def process_liquid_in_hash(hash) + return hash unless hash.is_a?(Hash) + + hash.transform_values { |value| process_liquid_value(value) } + end + + def process_liquid_value(value) + case value + when String + process_liquid_string(value) + when Hash + process_liquid_in_hash(value) + when Array + process_liquid_array(value) + else + value + end + end + + def process_liquid_array(array) + array.map { |item| process_liquid_value(item) } + end + + def process_liquid_string(string) + return string if string.blank? + + template = Liquid::Template.parse(string) + template.render(message_drops) + rescue Liquid::Error + string + end + + def message_drops + { + 'contact' => ContactDrop.new(contact), + 'agent' => UserDrop.new(campaign.sender), + 'inbox' => InboxDrop.new(campaign.inbox), + 'account' => AccountDrop.new(campaign.account) + } + end +end diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb index de2713ac0..9dfca9049 100644 --- a/app/services/whatsapp/oneoff_campaign_service.rb +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -57,7 +57,7 @@ class Whatsapp::OneoffCampaignService return end - send_whatsapp_template_message(to: contact.phone_number) + send_whatsapp_template_message(contact: contact, to: contact.phone_number) end def process_audience(audience_labels) @@ -69,26 +69,45 @@ class Whatsapp::OneoffCampaignService 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 + def send_whatsapp_template_message(contact:, to:) + processed_template_params = process_liquid_templates(contact) + name, namespace, lang_code, processed_parameters = process_template(processed_template_params) return if name.blank? + send_template_to_whatsapp(to, name, namespace, lang_code, processed_parameters) + rescue StandardError => e + log_template_send_error(to, e) + end + + def process_liquid_templates(contact) + liquid_processor = Whatsapp::CampaignLiquidProcessorService.new( + campaign: campaign, + contact: contact + ) + liquid_processor.call(campaign.template_params) + end + + def process_template(template_params) + processor = Whatsapp::TemplateProcessorService.new( + channel: channel, + template_params: template_params + ) + processor.call + end + + def send_template_to_whatsapp(to, name, namespace, lang_code, parameters) channel.send_template(to, { name: name, namespace: namespace, lang_code: lang_code, - parameters: processed_parameters + parameters: parameters }, nil) + end - 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')}" + def log_template_send_error(to, error) + Rails.logger.error "Failed to send WhatsApp template message to #{to}: #{error.message}" + Rails.logger.error "Backtrace: #{error.backtrace.first(5).join('\n')}" # continue processing remaining contacts nil end diff --git a/spec/services/whatsapp/campaign_liquid_processor_service_spec.rb b/spec/services/whatsapp/campaign_liquid_processor_service_spec.rb new file mode 100644 index 000000000..0796aa9e9 --- /dev/null +++ b/spec/services/whatsapp/campaign_liquid_processor_service_spec.rb @@ -0,0 +1,193 @@ +require 'rails_helper' + +RSpec.describe Whatsapp::CampaignLiquidProcessorService 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!(:inbox) { whatsapp_channel.inbox } + let(:campaign) { create(:campaign, inbox: inbox, account: account) } + let(:contact) do + create(:contact, + account: account, + name: 'John Doe', + email: 'john@example.com', + phone_number: '+1234567890', + custom_attributes: { customer_type: 'premium', membership_level: 'gold' }) + end + + let(:service) { described_class.new(campaign: campaign, contact: contact) } + + describe '#call' do + context 'when template_params is blank' do + it 'returns the original template_params' do + result = service.call(nil) + expect(result).to be_nil + + result = service.call({}) + expect(result).to eq({}) + end + end + + context 'when template_params has no processed_params' do + let(:template_params) { { 'name' => 'test_template' } } + + it 'returns the original template_params' do + result = service.call(template_params) + expect(result).to eq(template_params) + end + end + + context 'when template_params contains liquid variables' do + let(:template_params) do + { + 'name' => 'greet_template', + 'category' => 'MARKETING', + 'language' => 'en', + 'processed_params' => { + 'body' => { + 'customer_name' => '{{contact.name}}', + 'customer_email' => '{{contact.email}}' + } + } + } + end + + it 'processes liquid variables in template_params' do + result = service.call(template_params) + + expect(result['processed_params']['body']['customer_name']).to eq('John Doe') + expect(result['processed_params']['body']['customer_email']).to eq('john@example.com') + end + + it 'preserves non-liquid content' do + result = service.call(template_params) + + expect(result['name']).to eq('greet_template') + expect(result['category']).to eq('MARKETING') + expect(result['language']).to eq('en') + end + + it 'does not modify the original template_params' do + original_params = template_params.deep_dup + service.call(template_params) + + expect(template_params).to eq(original_params) + end + end + + context 'when template_params contains nested structures' do + let(:template_params) do + { + 'name' => 'complex_template', + 'processed_params' => { + 'header' => { + 'media_url' => 'https://example.com/{{contact.name}}.jpg' + }, + 'body' => { + 'customer_name' => '{{contact.name}}', + 'account_name' => '{{account.name}}' + }, + 'footer' => { + 'company_info' => 'Contact {{inbox.name}}' + }, + 'buttons' => [ + { 'type' => 'url', 'parameter' => 'https://portal.com/{{contact.name}}' }, + { 'type' => 'text', 'parameter' => 'Hello {{contact.name}}!' } + ] + } + } + end + + it 'processes liquid variables in nested hash structures' do + result = service.call(template_params) + + expect(result['processed_params']['header']['media_url']).to eq('https://example.com/John Doe.jpg') + expect(result['processed_params']['body']['customer_name']).to eq('John Doe') + expect(result['processed_params']['body']['account_name']).to eq(account.name) + expect(result['processed_params']['footer']['company_info']).to eq("Contact #{inbox.name}") + end + + it 'processes liquid variables in array elements' do + result = service.call(template_params) + + buttons = result['processed_params']['buttons'] + expect(buttons[0]['parameter']).to eq('https://portal.com/John Doe') + expect(buttons[1]['parameter']).to eq('Hello John Doe!') + end + end + + context 'when template_params contains custom attributes' do + let(:template_params) do + { + 'name' => 'custom_attr_template', + 'processed_params' => { + 'body' => { + 'customer_type' => '{{contact.custom_attribute.customer_type}}', + 'membership_level' => '{{contact.custom_attribute.membership_level}}' + } + } + } + end + + it 'processes custom attributes correctly' do + result = service.call(template_params) + + expect(result['processed_params']['body']['customer_type']).to eq('premium') + expect(result['processed_params']['body']['membership_level']).to eq('gold') + end + end + + context 'when template_params contains liquid filters' do + let(:template_params) do + { + 'name' => 'filter_template', + 'processed_params' => { + 'body' => { + 'customer_email' => '{{ contact.email | default: "no-email@example.com" }}', + 'customer_name_upper' => '{{ contact.name | upcase }}' + } + } + } + end + + it 'processes liquid filters correctly' do + result = service.call(template_params) + + expect(result['processed_params']['body']['customer_email']).to eq('john@example.com') + expect(result['processed_params']['body']['customer_name_upper']).to eq('JOHN DOE') + end + + context 'when contact email is nil' do + before { contact.update!(email: nil) } + + it 'uses default filter value' do + result = service.call(template_params) + + expect(result['processed_params']['body']['customer_email']).to eq('no-email@example.com') + end + end + end + + context 'when template_params contains invalid liquid syntax' do + let(:template_params) do + { + 'name' => 'broken_template', + 'processed_params' => { + 'body' => { + 'broken_liquid' => '{{contact.name} invalid}', + 'valid_liquid' => '{{contact.name}}' + } + } + } + end + + it 'handles broken liquid gracefully' do + result = service.call(template_params) + + expect(result['processed_params']['body']['broken_liquid']).to eq('{{contact.name} invalid}') + expect(result['processed_params']['body']['valid_liquid']).to eq('John Doe') + end + end + end +end diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb index dd8d51c54..467597ab4 100644 --- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -114,6 +114,17 @@ describe Whatsapp::OneoffCampaignService do described_class.new(campaign: campaign).perform end + it 'uses liquid processor service to process liquid templates' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(Whatsapp::CampaignLiquidProcessorService).to receive(:new) + .with(campaign: campaign, contact: contact) + .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]) @@ -176,5 +187,41 @@ describe Whatsapp::OneoffCampaignService do expect(campaign.reload.completed?).to be true end end + + context 'when template_params contains liquid variables' do + let(:template_params) do + { + 'name' => 'personalized_template', + 'namespace' => 'test_namespace', + 'category' => 'MARKETING', + 'language' => 'en', + 'processed_params' => { + 'body' => { + 'customer_name' => '{{contact.name}}', + 'customer_email' => '{{contact.email}}' + } + } + } + end + + it 'processes liquid variables before sending template message' do + contact = create(:contact, :with_phone_number, account: account, name: 'Jane Smith', email: 'jane@example.com') + contact.update_labels([label1.title]) + + # Expect the template processor to receive processed liquid variables + expect(Whatsapp::TemplateProcessorService).to receive(:new) + .with(channel: whatsapp_channel, template_params: hash_including( + 'processed_params' => hash_including( + 'body' => hash_including( + 'customer_name' => 'Jane Smith', + 'customer_email' => 'jane@example.com' + ) + ) + )) + .and_call_original + + described_class.new(campaign: campaign).perform + end + end end end