From 68bfbc7eb0fa8b7d7a49ed350b6d9fdf243421c3 Mon Sep 17 00:00:00 2001 From: Michael Choi <1226798+choilive@users.noreply.github.com> Date: Wed, 11 Jun 2025 12:16:44 -0500 Subject: [PATCH] feat: Add liquid processing for SMS campaigns (#10981) Liquid template processing for SMS campaigns fixes: https://github.com/chatwoot/chatwoot/issues/10980 Co-authored-by: Sojan Jose --- .../liquid/campaign_template_service.rb | 26 +++++ .../sms/oneoff_sms_campaign_service.rb | 3 +- .../twilio/oneoff_sms_campaign_service.rb | 3 +- .../liquid/campaign_template_service_spec.rb | 107 ++++++++++++++++++ .../sms/oneoff_sms_campaign_service_spec.rb | 9 ++ .../oneoff_sms_campaign_service_spec.rb | 10 ++ 6 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 app/services/liquid/campaign_template_service.rb create mode 100644 spec/services/liquid/campaign_template_service_spec.rb diff --git a/app/services/liquid/campaign_template_service.rb b/app/services/liquid/campaign_template_service.rb new file mode 100644 index 000000000..db273087d --- /dev/null +++ b/app/services/liquid/campaign_template_service.rb @@ -0,0 +1,26 @@ +class Liquid::CampaignTemplateService + pattr_initialize [:campaign!, :contact!] + + def call(message) + process_liquid_in_content(message_drops, message) + end + + private + + def message_drops + { + 'contact' => ContactDrop.new(contact), + 'agent' => UserDrop.new(campaign.sender), + 'inbox' => InboxDrop.new(campaign.inbox), + 'account' => AccountDrop.new(campaign.account) + } + end + + def process_liquid_in_content(drops, message) + message = message.gsub(/`(.*?)`/m, '{% raw %}`\\1`{% endraw %}') + template = Liquid::Template.parse(message) + template.render(drops) + rescue Liquid::Error + message + end +end diff --git a/app/services/sms/oneoff_sms_campaign_service.rb b/app/services/sms/oneoff_sms_campaign_service.rb index 73a101d24..22d38ac20 100644 --- a/app/services/sms/oneoff_sms_campaign_service.rb +++ b/app/services/sms/oneoff_sms_campaign_service.rb @@ -22,7 +22,8 @@ class Sms::OneoffSmsCampaignService campaign.account.contacts.tagged_with(audience_labels, any: true).each do |contact| next if contact.phone_number.blank? - send_message(to: contact.phone_number, content: campaign.message) + content = Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact).call(campaign.message) + send_message(to: contact.phone_number, content: content) end end diff --git a/app/services/twilio/oneoff_sms_campaign_service.rb b/app/services/twilio/oneoff_sms_campaign_service.rb index c16ec87b9..e99df36cc 100644 --- a/app/services/twilio/oneoff_sms_campaign_service.rb +++ b/app/services/twilio/oneoff_sms_campaign_service.rb @@ -22,7 +22,8 @@ class Twilio::OneoffSmsCampaignService campaign.account.contacts.tagged_with(audience_labels, any: true).each do |contact| next if contact.phone_number.blank? - channel.send_message(to: contact.phone_number, body: campaign.message) + content = Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact).call(campaign.message) + channel.send_message(to: contact.phone_number, body: content) end end end diff --git a/spec/services/liquid/campaign_template_service_spec.rb b/spec/services/liquid/campaign_template_service_spec.rb new file mode 100644 index 000000000..40034e410 --- /dev/null +++ b/spec/services/liquid/campaign_template_service_spec.rb @@ -0,0 +1,107 @@ +require 'rails_helper' + +describe Liquid::CampaignTemplateService do + subject(:template_service) { described_class.new(campaign: campaign, contact: contact) } + + let(:account) { create(:account) } + let(:agent) { create(:user, account: account) } + let(:inbox) { create(:inbox, account: account) } + let(:contact) { create(:contact, account: account, name: 'John Doe', phone_number: '+1234567890') } + let(:campaign) { create(:campaign, account: account, inbox: inbox, sender: agent, message: message_content) } + + describe '#call' do + context 'with liquid template variables' do + let(:message_content) { 'Hello {{contact.name}}, this is {{agent.name}} from {{account.name}}' } + + it 'processes liquid template correctly' do + result = template_service.call(message_content) + agent_drop_name = UserDrop.new(agent).name + contact_drop_name = ContactDrop.new(contact).name + + expect(result).to eq("Hello #{contact_drop_name}, this is #{agent_drop_name} from #{account.name}") + end + end + + context 'with code blocks' do + let(:message_content) { 'Check this code: `const x = {{contact.name}}`' } + + it 'preserves code blocks without processing liquid' do + result = template_service.call(message_content) + + expect(result).to include('`const x = {{contact.name}}`') + expect(result).not_to include(contact.name) + end + end + + context 'with multiline code blocks' do + let(:message_content) do + <<~MESSAGE + Here's some code: + ``` + function greet() { + return "Hello {{contact.name}}"; + } + ``` + MESSAGE + end + + it 'preserves multiline code blocks without processing liquid' do + result = template_service.call(message_content) + + expect(result).to include('{{contact.name}}') + expect(result).not_to include(contact.name) + end + end + + context 'with malformed liquid syntax' do + let(:message_content) { 'Hello {{contact.name missing closing braces' } + + it 'returns original message when liquid parsing fails' do + result = template_service.call(message_content) + + expect(result).to eq(message_content) + end + end + + context 'with invalid liquid tags' do + let(:message_content) { 'Hello {% invalid_tag %} world' } + + it 'returns original message when liquid parsing fails' do + result = template_service.call(message_content) + + expect(result).to eq(message_content) + end + end + + context 'with mixed content' do + let(:message_content) { 'Hi {{contact.name}}, use this code: `{{agent.name}}` to contact {{agent.name}}' } + + it 'processes liquid outside code blocks but preserves code blocks' do + result = template_service.call(message_content) + agent_drop_name = UserDrop.new(agent).name + contact_drop_name = ContactDrop.new(contact).name + + expect(result).to include("Hi #{contact_drop_name}") + expect(result).to include("contact #{agent_drop_name}") + expect(result).to include('`{{agent.name}}`') + end + end + + context 'with all drop types' do + let(:message_content) do + 'Contact: {{contact.name}}, Agent: {{agent.name}}, Inbox: {{inbox.name}}, Account: {{account.name}}' + end + + it 'processes all available drops' do + result = template_service.call(message_content) + agent_drop_name = UserDrop.new(agent).name + contact_drop_name = ContactDrop.new(contact).name + + expect(result).to include("Contact: #{contact_drop_name}") + expect(result).to include("Agent: #{agent_drop_name}") + expect(result).to include("Inbox: #{inbox.name}") + expect(result).to include("Account: #{account.name}") + end + end + end +end diff --git a/spec/services/sms/oneoff_sms_campaign_service_spec.rb b/spec/services/sms/oneoff_sms_campaign_service_spec.rb index 2f7e5bbf4..a5c7d7e58 100644 --- a/spec/services/sms/oneoff_sms_campaign_service_spec.rb +++ b/spec/services/sms/oneoff_sms_campaign_service_spec.rb @@ -43,5 +43,14 @@ describe Sms::OneoffSmsCampaignService do assert_requested(:post, 'https://messaging.bandwidth.com/api/v2/users/1/messages', times: 3) expect(campaign.reload.completed?).to be true end + + it 'uses liquid template service to process campaign message' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(Liquid::CampaignTemplateService).to receive(:new).with(campaign: campaign, contact: contact).and_call_original + + sms_campaign_service.perform + end end end diff --git a/spec/services/twilio/oneoff_sms_campaign_service_spec.rb b/spec/services/twilio/oneoff_sms_campaign_service_spec.rb index f8858f174..0e6a993c2 100644 --- a/spec/services/twilio/oneoff_sms_campaign_service_spec.rb +++ b/spec/services/twilio/oneoff_sms_campaign_service_spec.rb @@ -60,5 +60,15 @@ describe Twilio::OneoffSmsCampaignService do sms_campaign_service.perform expect(campaign.reload.completed?).to be true end + + it 'uses liquid template service to process campaign message' do + contact = create(:contact, :with_phone_number, account: account) + contact.update_labels([label1.title]) + + expect(Liquid::CampaignTemplateService).to receive(:new).with(campaign: campaign, contact: contact).and_call_original + expect(twilio_messages).to receive(:create).once + + sms_campaign_service.perform + end end end