feat: Add liquid template support for WhatsApp campaigns
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user