feat: Add liquid template support for WhatsApp campaigns

This commit is contained in:
Muhsin Keloth
2025-08-19 18:40:56 +05:30
parent 7fe94dc1a2
commit c835308687
4 changed files with 325 additions and 12 deletions
@@ -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
@@ -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
@@ -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
@@ -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