feat(campaigns): Add variable support to WhatsApp campaigns (#13649)
Fixes https://linear.app/chatwoot/issue/CW-5641/add-the-support-for-variables-in-whatsapp-campaign-templates This PR adds liquid variable support to WhatsApp campaigns, enabling dynamic per-contact personalization. It supports 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. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew <sony@chatwoot.com>
This commit is contained in:
co-authored by
Sojan Jose
Muhsin
Sony Mathew
parent
6aeda0ddf6
commit
f8f0caf443
@@ -0,0 +1,66 @@
|
||||
class Whatsapp::LiquidTemplateProcessorService
|
||||
LIQUID_EXPRESSION = /\{\{\s*(.+?)\s*\}\}/
|
||||
|
||||
module JsonEscapeFilter
|
||||
def json_escape(input)
|
||||
input.to_s.to_json[1..-2]
|
||||
end
|
||||
end
|
||||
|
||||
pattr_initialize [:campaign!, :contact!]
|
||||
|
||||
def process_template_params(template_params)
|
||||
return template_params if template_params.blank?
|
||||
|
||||
template_params_copy = template_params.deep_dup
|
||||
processed_params = template_params_copy['processed_params']
|
||||
|
||||
return template_params_copy if processed_params.blank?
|
||||
|
||||
rendered_params = render_liquid(processed_params)
|
||||
return nil if blank_render?(processed_params, rendered_params)
|
||||
|
||||
template_params_copy.merge('processed_params' => rendered_params)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_liquid(processed_params)
|
||||
raw = processed_params.to_json
|
||||
rewritten = raw.gsub(LIQUID_EXPRESSION) { "{{ #{Regexp.last_match(1)} | json_escape }}" }
|
||||
rendered = Liquid::Template.parse(rewritten).render!(drops, filters: [JsonEscapeFilter])
|
||||
JSON.parse(rendered)
|
||||
rescue Liquid::Error, JSON::ParserError
|
||||
processed_params
|
||||
end
|
||||
|
||||
def drops
|
||||
{
|
||||
'contact' => ContactDrop.new(contact),
|
||||
'agent' => UserDrop.new(campaign.sender),
|
||||
'inbox' => InboxDrop.new(campaign.inbox),
|
||||
'account' => AccountDrop.new(campaign.account)
|
||||
}
|
||||
end
|
||||
|
||||
def blank_render?(original, rendered)
|
||||
case original
|
||||
when Hash then blank_render_in_hash?(original, rendered)
|
||||
when Array then blank_render_in_array?(original, rendered)
|
||||
when String then original.match?(LIQUID_EXPRESSION) && rendered.to_s.blank?
|
||||
else false
|
||||
end
|
||||
end
|
||||
|
||||
def blank_render_in_hash?(original, rendered)
|
||||
return false unless rendered.is_a?(Hash)
|
||||
|
||||
original.any? { |key, value| blank_render?(value, rendered[key]) }
|
||||
end
|
||||
|
||||
def blank_render_in_array?(original, rendered)
|
||||
return false unless rendered.is_a?(Array)
|
||||
|
||||
original.each_with_index.any? { |value, index| blank_render?(value, rendered[index]) }
|
||||
end
|
||||
end
|
||||
@@ -58,7 +58,10 @@ class Whatsapp::OneoffCampaignService
|
||||
return
|
||||
end
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number)
|
||||
processed_template_params = process_liquid_template_params(contact)
|
||||
return if processed_template_params.nil?
|
||||
|
||||
send_whatsapp_template_message(to: contact.phone_number, template_params: processed_template_params)
|
||||
end
|
||||
|
||||
def process_audience(audience_labels)
|
||||
@@ -70,10 +73,22 @@ class Whatsapp::OneoffCampaignService
|
||||
Rails.logger.info "Campaign #{campaign.id} processing completed"
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:)
|
||||
def process_liquid_template_params(contact)
|
||||
liquid_processor = Whatsapp::LiquidTemplateProcessorService.new(campaign: campaign, contact: contact)
|
||||
processed_template_params = liquid_processor.process_template_params(campaign.template_params)
|
||||
|
||||
Rails.logger.info "Skipping contact #{contact.name} - liquid variables resolved to blank values" if processed_template_params.nil?
|
||||
|
||||
processed_template_params
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to process liquid template params for contact #{contact.name}: #{e.message}"
|
||||
nil
|
||||
end
|
||||
|
||||
def send_whatsapp_template_message(to:, template_params:)
|
||||
processor = Whatsapp::TemplateProcessorService.new(
|
||||
channel: channel,
|
||||
template_params: campaign.template_params
|
||||
template_params: template_params
|
||||
)
|
||||
|
||||
name, namespace, lang_code, processed_parameters = processor.call
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
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 'when liquid variable resolves to blank' do
|
||||
let(:contact) { create(:contact, account: account, name: 'John', email: nil, phone_number: '+1234567890') }
|
||||
|
||||
it 'returns nil for enhanced params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'email' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for legacy hash params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'1' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for legacy array params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => ['{{contact.email}}']
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for button params with blank rendered values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'buttons' => [
|
||||
{ 'type' => 'url', 'parameter' => '{{contact.email}}' }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it 'returns processed params when all variables resolve to non-blank values' do
|
||||
template_params = {
|
||||
'name' => 'test_template',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'name' => '{{contact.name}}'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result = service.process_template_params(template_params)
|
||||
expect(result).not_to be_nil
|
||||
expect(result['processed_params']['body']['name']).to eq(ContactDrop.new(contact).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
|
||||
|
||||
context 'with legacy flat hash processed_params' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'legacy_template',
|
||||
'processed_params' => {
|
||||
'1' => '{{contact.name}}',
|
||||
'2' => '{{contact.email}}',
|
||||
'3' => 'Hello World'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in legacy hash values' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['1']).to eq(contact_drop_name)
|
||||
expect(result['processed_params']['2']).to eq(contact.email)
|
||||
expect(result['processed_params']['3']).to eq('Hello World')
|
||||
end
|
||||
|
||||
it 'treats component-named string values as legacy params' do
|
||||
params_with_component_named_key = {
|
||||
'name' => 'legacy_template',
|
||||
'processed_params' => { 'body' => '{{contact.name}}' }
|
||||
}
|
||||
|
||||
result = service.process_template_params(params_with_component_named_key)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']['body']).to eq(contact_drop_name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with legacy array processed_params' do
|
||||
let(:template_params) do
|
||||
{
|
||||
'name' => 'legacy_template',
|
||||
'processed_params' => ['{{contact.name}}', '{{contact.email}}', 'Hello World']
|
||||
}
|
||||
end
|
||||
|
||||
it 'processes liquid variables in legacy array values' do
|
||||
result = service.process_template_params(template_params)
|
||||
contact_drop_name = ContactDrop.new(contact).name
|
||||
|
||||
expect(result['processed_params']).to eq([contact_drop_name, contact.email, 'Hello World'])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -139,6 +139,73 @@ 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
|
||||
|
||||
it 'skips contacts when liquid variables resolve to blank values' do
|
||||
contact = create(:contact, :with_phone_number, account: account, name: 'Jane', email: nil)
|
||||
contact.update_labels([label1.title])
|
||||
|
||||
campaign_with_blank_liquid = create(:campaign, inbox: whatsapp_inbox, account: account,
|
||||
audience: [{ type: 'Label', id: label1.id }],
|
||||
template_params: {
|
||||
'name' => 'test_template',
|
||||
'namespace' => 'test_namespace',
|
||||
'language' => 'en',
|
||||
'processed_params' => {
|
||||
'body' => {
|
||||
'email' => '{{contact.email}}'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(whatsapp_channel).not_to receive(:send_template)
|
||||
expect(Rails.logger).to receive(:info).with("Skipping contact #{contact.name} - liquid variables resolved to blank values")
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
described_class.new(campaign: campaign_with_blank_liquid).perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when template_params is missing' do
|
||||
|
||||
Reference in New Issue
Block a user