From f8f0caf4438b2e9f0c479e87e94cc4f926156ca5 Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 28 Apr 2026 21:57:30 +0400 Subject: [PATCH] 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 Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: Sony Mathew --- .../liquid_template_processor_service.rb | 66 ++++ .../whatsapp/oneoff_campaign_service.rb | 21 +- .../liquid_template_processor_service_spec.rb | 347 ++++++++++++++++++ .../whatsapp/oneoff_campaign_service_spec.rb | 67 ++++ 4 files changed, 498 insertions(+), 3 deletions(-) create mode 100644 app/services/whatsapp/liquid_template_processor_service.rb create mode 100644 spec/services/whatsapp/liquid_template_processor_service_spec.rb diff --git a/app/services/whatsapp/liquid_template_processor_service.rb b/app/services/whatsapp/liquid_template_processor_service.rb new file mode 100644 index 000000000..6703b206c --- /dev/null +++ b/app/services/whatsapp/liquid_template_processor_service.rb @@ -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 diff --git a/app/services/whatsapp/oneoff_campaign_service.rb b/app/services/whatsapp/oneoff_campaign_service.rb index 5917838cb..7826f71fd 100644 --- a/app/services/whatsapp/oneoff_campaign_service.rb +++ b/app/services/whatsapp/oneoff_campaign_service.rb @@ -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 diff --git a/spec/services/whatsapp/liquid_template_processor_service_spec.rb b/spec/services/whatsapp/liquid_template_processor_service_spec.rb new file mode 100644 index 000000000..5b11b7409 --- /dev/null +++ b/spec/services/whatsapp/liquid_template_processor_service_spec.rb @@ -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 diff --git a/spec/services/whatsapp/oneoff_campaign_service_spec.rb b/spec/services/whatsapp/oneoff_campaign_service_spec.rb index dd8d51c54..00d7fdd3b 100644 --- a/spec/services/whatsapp/oneoff_campaign_service_spec.rb +++ b/spec/services/whatsapp/oneoff_campaign_service_spec.rb @@ -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