From bd698cb12cf7f2681c3ba36a7a60008c5c0b38df Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Tue, 6 Jan 2026 10:38:36 +0400 Subject: [PATCH] feat: Add call-to-action template support for Twilio (#13179) Fixes https://linear.app/chatwoot/issue/CW-6228/add-call-to-action-template-support-for-twilio-whatsapp-templates Adds support for Twilio WhatsApp call-to-action templates, enabling customers to use URL button templates with variable inputs. CleanShot 2026-01-05 at 16 25
55@2x --- .../ContentTemplatesPicker.vue | 3 ++ .../i18n/locale/en/contentTemplates.json | 1 + app/javascript/shared/constants/messages.js | 1 + .../twilio/template_processor_service.rb | 4 +- app/services/twilio/template_sync_service.rb | 4 ++ .../twilio/template_sync_service_spec.rb | 52 ++++++++++++++++++- 6 files changed, 61 insertions(+), 4 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue b/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue index 89d82fe0c..c36f38b73 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ContentTemplates/ContentTemplatesPicker.vue @@ -41,6 +41,9 @@ const getTemplateType = template => { if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) { return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY'); } + if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.CALL_TO_ACTION) { + return t('CONTENT_TEMPLATES.PICKER.TYPES.CALL_TO_ACTION'); + } return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT'); }; diff --git a/app/javascript/dashboard/i18n/locale/en/contentTemplates.json b/app/javascript/dashboard/i18n/locale/en/contentTemplates.json index a9b1d54c4..79c2c8c64 100644 --- a/app/javascript/dashboard/i18n/locale/en/contentTemplates.json +++ b/app/javascript/dashboard/i18n/locale/en/contentTemplates.json @@ -28,6 +28,7 @@ "TYPES": { "MEDIA": "Media", "QUICK_REPLY": "Quick Reply", + "CALL_TO_ACTION": "Call to Action", "TEXT": "Text" } }, diff --git a/app/javascript/shared/constants/messages.js b/app/javascript/shared/constants/messages.js index 8e9d06beb..989aa12ca 100644 --- a/app/javascript/shared/constants/messages.js +++ b/app/javascript/shared/constants/messages.js @@ -164,4 +164,5 @@ export const TWILIO_CONTENT_TEMPLATE_TYPES = { TEXT: 'text', MEDIA: 'media', QUICK_REPLY: 'quick_reply', + CALL_TO_ACTION: 'call_to_action', }; diff --git a/app/services/twilio/template_processor_service.rb b/app/services/twilio/template_processor_service.rb index 0c0faf9b1..2801f743f 100644 --- a/app/services/twilio/template_processor_service.rb +++ b/app/services/twilio/template_processor_service.rb @@ -23,8 +23,8 @@ class Twilio::TemplateProcessorService def build_content_variables(template) case template['template_type'] - when 'text', 'quick_reply' - convert_text_template(template_params) # Text and quick reply templates use body variables + when 'text', 'quick_reply', 'call_to_action' + convert_text_template(template_params) # Text, quick reply and call-to-action templates use body variables when 'media' convert_media_template(template_params) else diff --git a/app/services/twilio/template_sync_service.rb b/app/services/twilio/template_sync_service.rb index d30a6cde9..747c50f43 100644 --- a/app/services/twilio/template_sync_service.rb +++ b/app/services/twilio/template_sync_service.rb @@ -63,6 +63,8 @@ class Twilio::TemplateSyncService 'media' elsif template_types.include?('twilio/quick-reply') 'quick_reply' + elsif template_types.include?('twilio/call-to-action') + 'call_to_action' elsif template_types.include?('twilio/catalog') 'catalog' else @@ -107,6 +109,8 @@ class Twilio::TemplateSyncService template_types['twilio/media']['body'] elsif template_types['twilio/quick-reply'] template_types['twilio/quick-reply']['body'] + elsif template_types['twilio/call-to-action'] + template_types['twilio/call-to-action']['body'] elsif template_types['twilio/catalog'] template_types['twilio/catalog']['body'] else diff --git a/spec/services/twilio/template_sync_service_spec.rb b/spec/services/twilio/template_sync_service_spec.rb index 26d2db0e2..472ae8821 100644 --- a/spec/services/twilio/template_sync_service_spec.rb +++ b/spec/services/twilio/template_sync_service_spec.rb @@ -81,7 +81,29 @@ RSpec.describe Twilio::TemplateSyncService do ) end - let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] } + let(:call_to_action_template) do + instance_double( + Twilio::REST::Content::V1::ContentInstance, + sid: 'HX444555666', + friendly_name: 'payment_reminder', + language: 'en', + date_created: Time.current, + date_updated: Time.current, + variables: {}, + types: { + 'twilio/call-to-action' => { + 'body' => 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \ + '\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \ + '\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience', + 'actions' => [ + { 'id' => 'make_payment', 'title' => 'Make Payment', 'url' => 'https://example.com/payment' } + ] + } + } + ) + end + + let(:templates) { [text_template, media_template, quick_reply_template, catalog_template, call_to_action_template] } before do allow(twilio_channel).to receive(:send).and_call_original @@ -104,7 +126,7 @@ RSpec.describe Twilio::TemplateSyncService do twilio_channel.reload expect(twilio_channel.content_templates).to be_present expect(twilio_channel.content_templates['templates']).to be_an(Array) - expect(twilio_channel.content_templates['templates'].size).to eq(4) + expect(twilio_channel.content_templates['templates'].size).to eq(5) expect(twilio_channel.content_templates_last_updated).to be_within(1.second).of(Time.current) end end @@ -172,6 +194,32 @@ RSpec.describe Twilio::TemplateSyncService do ) end + it 'correctly formats call-to-action templates with variables' do + sync_service.call + + twilio_channel.reload + call_to_action_data = twilio_channel.content_templates['templates'].find do |t| + t['friendly_name'] == 'payment_reminder' + end + + expect(call_to_action_data).to include( + 'content_sid' => 'HX444555666', + 'friendly_name' => 'payment_reminder', + 'language' => 'en', + 'status' => 'approved', + 'template_type' => 'call_to_action', + 'media_type' => nil, + 'variables' => {}, + 'category' => 'utility' + ) + + expected_body = 'Hello, this is a gentle reminder regarding your RVA Astrology course fee.' \ + '\n\n• Vignana Course: ₹3,000\n• Panditha Course: ₹6,000' \ + '\n\nThe payment is due on {{date}}.\nKindly complete the payment at your convenience' + expect(call_to_action_data['body']).to eq(expected_body) + expect(call_to_action_data['body']).to match(/{{date}}/) + end + it 'categorizes marketing templates correctly' do marketing_template = instance_double( Twilio::REST::Content::V1::ContentInstance,