feat(campaigns): add liquid variable support to WhatsApp campaigns

- Add liquid variable support to WhatsApp campaigns, enabling dynamic per-contact personalization
  - WhatsApp campaigns now support the same liquid variables as SMS campaigns (`{{contact.name}}`, `{{contact.email}}`, etc.)
  - Variables are processed per-contact when the campaign executes, allowing personalized messages at scale
This commit is contained in:
Muhsin
2026-02-16 13:05:26 +05:30
parent fd5ac2a8a3
commit 0b65fa2270
4 changed files with 354 additions and 3 deletions
@@ -0,0 +1,76 @@
class Whatsapp::LiquidTemplateProcessorService
pattr_initialize [:campaign!, :contact!]
def process_template_params(template_params)
return template_params if template_params.blank?
template_params_copy = deep_copy(template_params)
processed_params = template_params_copy['processed_params']
return template_params_copy if processed_params.blank?
process_body_params(processed_params)
process_header_params(processed_params)
process_button_params(processed_params)
process_footer_params(processed_params)
template_params_copy
end
private
def process_body_params(processed_params)
return if processed_params['body'].blank?
processed_params['body'].each do |key, value|
processed_params['body'][key] = process_liquid(value) if value.is_a?(String)
end
end
def process_header_params(processed_params)
return if processed_params['header'].blank?
processed_params['header'].each do |key, value|
processed_params['header'][key] = process_liquid(value) if value.is_a?(String)
end
end
def process_button_params(processed_params)
return if processed_params['buttons'].blank?
processed_params['buttons'].each do |button|
next if button.blank?
button['parameter'] = process_liquid(button['parameter']) if button['parameter'].is_a?(String)
end
end
def process_footer_params(processed_params)
return if processed_params['footer'].blank?
processed_params['footer'].each do |key, value|
processed_params['footer'][key] = process_liquid(value) if value.is_a?(String)
end
end
def process_liquid(value)
return value if value.blank?
liquid_service.call(value)
end
def liquid_service
@liquid_service ||= Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact)
end
def deep_copy(obj)
case obj
when Hash
obj.transform_values { |v| deep_copy(v) }
when Array
obj.map { |v| deep_copy(v) }
else
obj.duplicable? ? obj.dup : obj
end
end
end
@@ -58,7 +58,7 @@ class Whatsapp::OneoffCampaignService
return
end
send_whatsapp_template_message(to: contact.phone_number)
send_whatsapp_template_message(to: contact.phone_number, contact: contact)
end
def process_audience(audience_labels)
@@ -70,10 +70,14 @@ class Whatsapp::OneoffCampaignService
Rails.logger.info "Campaign #{campaign.id} processing completed"
end
def send_whatsapp_template_message(to:)
def send_whatsapp_template_message(to:, contact:)
# Process template params with liquid variables for this contact
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
processor = Whatsapp::TemplateProcessorService.new(
channel: channel,
template_params: campaign.template_params
template_params: processed_template_params
)
name, namespace, lang_code, processed_parameters = processor.call
@@ -0,0 +1,228 @@
require 'rails_helper'
describe Whatsapp::LiquidTemplateProcessorService do
let(:account) { create(:account) }
let(:agent) { create(:user, account: account, name: 'Agent Smith') }
let(:inbox) { create(:inbox, account: account, name: 'Support Inbox') }
let(:contact) { create(:contact, account: account, name: 'John Doe', email: 'john@example.com', phone_number: '+1234567890') }
let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: 'Test message') }
let(:service) { described_class.new(campaign: campaign, contact: contact) }
describe '#process_template_params' do
context 'when template_params is blank' do
it 'returns the original template_params' do
result = service.process_template_params(nil)
expect(result).to be_nil
end
end
context 'when processed_params is blank' do
let(:template_params) { { 'name' => 'test_template' } }
it 'returns the original template_params' do
result = service.process_template_params(template_params)
expect(result).to eq(template_params)
end
end
context 'with body parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'namespace' => 'test_namespace',
'language' => 'en',
'processed_params' => {
'body' => {
'name' => '{{contact.name}}',
'email' => '{{contact.email}}',
'static_text' => 'Hello World'
}
}
}
end
it 'processes liquid variables in body parameters' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['body']['name']).to eq(contact_drop_name)
expect(result['processed_params']['body']['email']).to eq(contact.email)
expect(result['processed_params']['body']['static_text']).to eq('Hello World')
end
it 'does not modify the original template_params' do
original_name_value = template_params['processed_params']['body']['name']
service.process_template_params(template_params)
expect(template_params['processed_params']['body']['name']).to eq(original_name_value)
end
end
context 'with header parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'header' => {
'media_url' => 'https://example.com/{{contact.name}}.jpg',
'media_name' => '{{contact.name}}_document.pdf'
}
}
}
end
it 'processes liquid variables in header parameters' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['header']['media_url']).to eq("https://example.com/#{contact_drop_name}.jpg")
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_document.pdf")
end
end
context 'with button parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'buttons' => [
{ 'type' => 'url', 'parameter' => '{{contact.email}}' },
{ 'type' => 'copy_code', 'parameter' => 'CODE-{{contact.name}}' }
]
}
}
end
it 'processes liquid variables in button parameters' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
expect(result['processed_params']['buttons'][1]['parameter']).to eq("CODE-#{contact_drop_name}")
end
end
context 'with footer parameters containing liquid variables' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'footer' => {
'text' => 'From {{agent.name}} at {{account.name}}'
}
}
}
end
it 'processes liquid variables in footer parameters' do
result = service.process_template_params(template_params)
agent_drop_name = UserDrop.new(agent).name
expect(result['processed_params']['footer']['text']).to eq("From #{agent_drop_name} at #{account.name}")
end
end
context 'with multiple liquid variables across different sections' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'greeting' => 'Hello {{contact.name}}',
'agent' => 'Your agent is {{agent.name}}'
},
'header' => {
'media_name' => '{{contact.name}}_file.pdf'
},
'buttons' => [
{ 'parameter' => '{{contact.email}}' }
],
'footer' => {
'text' => '{{inbox.name}}'
}
}
}
end
it 'processes all liquid variables correctly' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
agent_drop_name = UserDrop.new(agent).name
expect(result['processed_params']['body']['greeting']).to eq("Hello #{contact_drop_name}")
expect(result['processed_params']['body']['agent']).to eq("Your agent is #{agent_drop_name}")
expect(result['processed_params']['header']['media_name']).to eq("#{contact_drop_name}_file.pdf")
expect(result['processed_params']['buttons'][0]['parameter']).to eq(contact.email)
expect(result['processed_params']['footer']['text']).to eq(inbox.name)
end
end
context 'with blank or nil values' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'name' => nil,
'email' => '',
'valid' => '{{contact.name}}'
}
}
}
end
it 'handles blank values gracefully' do
result = service.process_template_params(template_params)
contact_drop_name = ContactDrop.new(contact).name
expect(result['processed_params']['body']['name']).to be_nil
expect(result['processed_params']['body']['email']).to eq('')
expect(result['processed_params']['body']['valid']).to eq(contact_drop_name)
end
end
context 'with custom attributes' do
let(:contact) do
create(:contact, account: account, name: 'John Doe',
custom_attributes: { 'company' => 'Acme Inc', 'plan' => 'Premium' })
end
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'company' => '{{contact.custom_attribute.company}}',
'plan' => '{{contact.custom_attribute.plan}}'
}
}
}
end
it 'processes custom attribute liquid variables' do
result = service.process_template_params(template_params)
expect(result['processed_params']['body']['company']).to eq('Acme Inc')
expect(result['processed_params']['body']['plan']).to eq('Premium')
end
end
context 'with invalid liquid syntax' do
let(:template_params) do
{
'name' => 'test_template',
'processed_params' => {
'body' => {
'invalid' => '{{contact.name missing braces'
}
}
}
end
it 'returns original value when liquid parsing fails' do
result = service.process_template_params(template_params)
expect(result['processed_params']['body']['invalid']).to eq('{{contact.name missing braces')
end
end
end
end
@@ -139,6 +139,49 @@ describe Whatsapp::OneoffCampaignService do
described_class.new(campaign: campaign).perform
end
it 'processes liquid variables in template parameters' do
contact = create(:contact, :with_phone_number, account: account, name: 'Jane Smith', email: 'jane@example.com')
contact.update_labels([label1.title])
campaign_with_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
audience: [{ type: 'Label', id: label1.id }],
template_params: {
'name' => 'ticket_status_updated',
'namespace' => '23423423_2342423_324234234_2343224',
'category' => 'UTILITY',
'language' => 'en',
'processed_params' => {
'body' => {
'name' => '{{contact.name}}',
'ticket_id' => '{{contact.email}}'
}
}
})
contact_drop_name = ContactDrop.new(contact).name
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: 'body',
parameters: array_including(
hash_including(type: 'text', parameter_name: 'name', text: contact_drop_name),
hash_including(type: 'text', parameter_name: 'ticket_id', text: contact.email)
)
)
)
),
nil
)
described_class.new(campaign: campaign_with_liquid).perform
end
end
context 'when template_params is missing' do