feat: Added the backend support for twilio content templates (#12272)

Added comprehensive Twilio WhatsApp content template support (Phase 1)
enabling text, media, and quick reply templates with proper parameter
conversion, sync capabilities.

 **Template Types Supported**
  - Basic Text Templates: Simple text with variables ({{1}}, {{2}})
  - Media Templates: Image/Video/Document templates with text variables
  - Quick Reply Templates: Interactive button templates
  
 Front end changes is available via #12277

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2025-08-24 10:05:15 +05:30
committed by GitHub
parent 655db56be9
commit 7d6a43fc72
13 changed files with 1293 additions and 19 deletions

View File

@@ -0,0 +1,50 @@
require 'rails_helper'
RSpec.describe Channels::Twilio::TemplatesSyncJob do
let!(:account) { create(:account) }
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
it 'enqueues the job' do
expect { described_class.perform_later(twilio_channel) }.to have_enqueued_job(described_class)
.on_queue('low')
.with(twilio_channel)
end
describe '#perform' do
let(:template_sync_service) { instance_double(Twilio::TemplateSyncService) }
context 'with successful template sync' do
it 'creates and calls the template sync service' do
expect(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service)
expect(template_sync_service).to receive(:call).and_return(true)
described_class.perform_now(twilio_channel)
end
end
context 'with template sync exception' do
let(:error_message) { 'Twilio API error' }
before do
allow(Twilio::TemplateSyncService).to receive(:new).with(channel: twilio_channel).and_return(template_sync_service)
allow(template_sync_service).to receive(:call).and_raise(StandardError, error_message)
end
it 'does not suppress the exception' do
expect { described_class.perform_now(twilio_channel) }.to raise_error(StandardError, error_message)
end
end
context 'with nil channel' do
it 'handles nil channel gracefully' do
expect { described_class.perform_now(nil) }.to raise_error(NoMethodError)
end
end
end
describe 'job configuration' do
it 'is configured to run on low priority queue' do
expect(described_class.queue_name).to eq('low')
end
end
end

View File

@@ -0,0 +1,598 @@
require 'rails_helper'
RSpec.describe Twilio::TemplateProcessorService do
subject(:processor_service) { described_class.new(channel: twilio_channel, template_params: template_params, message: message) }
let!(:account) { create(:account) }
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let!(:contact) { create(:contact, account: account) }
let!(:inbox) { create(:inbox, channel: twilio_channel, account: account) }
let!(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox) }
let!(:conversation) { create(:conversation, contact: contact, inbox: inbox, contact_inbox: contact_inbox) }
let!(:message) { create(:message, conversation: conversation, account: account) }
let(:content_templates) do
{
'templates' => [
{
'content_sid' => 'HX123456789',
'friendly_name' => 'hello_world',
'language' => 'en',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Hello World!'
},
{
'content_sid' => 'HX987654321',
'friendly_name' => 'greet',
'language' => 'en',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => { '1' => 'John' },
'category' => 'utility',
'body' => 'Hello {{1}}!'
},
{
'content_sid' => 'HX555666777',
'friendly_name' => 'product_showcase',
'language' => 'en',
'status' => 'approved',
'template_type' => 'media',
'media_type' => 'image',
'variables' => { '1' => 'https://example.com/image.jpg', '2' => 'iPhone', '3' => '$999' },
'category' => 'marketing',
'body' => 'Check out {{2}} for {{3}}'
},
{
'content_sid' => 'HX111222333',
'friendly_name' => 'welcome_message',
'language' => 'en_US',
'status' => 'approved',
'template_type' => 'quick_reply',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Welcome! How can we help?'
},
{
'content_sid' => 'HX444555666',
'friendly_name' => 'order_status',
'language' => 'es',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => { '1' => 'Juan', '2' => 'ORD123' },
'category' => 'utility',
'body' => 'Hola {{1}}, tu pedido {{2}} está confirmado'
}
]
}
end
before do
twilio_channel.update!(content_templates: content_templates)
end
describe '#call' do
context 'with blank template_params' do
let(:template_params) { nil }
it 'returns nil values' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with empty template_params' do
let(:template_params) { {} }
it 'returns nil values' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with template not found' do
let(:template_params) do
{
'name' => 'nonexistent_template',
'language' => 'en'
}
end
it 'returns nil values' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with text templates' do
context 'with simple text template (no variables)' do
let(:template_params) do
{
'name' => 'hello_world',
'language' => 'en'
}
end
it 'returns content_sid and empty variables' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX123456789')
expect(content_variables).to eq({})
end
end
context 'with text template using processed_params format' do
let(:template_params) do
{
'name' => 'greet',
'language' => 'en',
'processed_params' => {
'1' => 'Alice',
'2' => 'Premium User'
}
}
end
it 'processes key-value parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX987654321')
expect(content_variables).to eq({
'1' => 'Alice',
'2' => 'Premium User'
})
end
end
context 'with text template using WhatsApp Cloud API format' do
let(:template_params) do
{
'name' => 'greet',
'language' => 'en',
'parameters' => [
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Bob' }
]
}
]
}
end
it 'processes WhatsApp format parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX987654321')
expect(content_variables).to eq({ '1' => 'Bob' })
end
end
context 'with multiple body parameters' do
let(:template_params) do
{
'name' => 'greet',
'language' => 'en',
'parameters' => [
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Charlie' },
{ 'type' => 'text', 'text' => 'VIP Member' }
]
}
]
}
end
it 'processes multiple parameters with sequential indexing' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX987654321')
expect(content_variables).to eq({
'1' => 'Charlie',
'2' => 'VIP Member'
})
end
end
end
context 'with quick reply templates' do
let(:template_params) do
{
'name' => 'welcome_message',
'language' => 'en_US'
}
end
it 'processes quick reply templates like text templates' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX111222333')
expect(content_variables).to eq({})
end
context 'with quick reply template having body parameters' do
let(:template_params) do
{
'name' => 'welcome_message',
'language' => 'en_US',
'parameters' => [
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Diana' }
]
}
]
}
end
it 'processes body parameters for quick reply templates' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX111222333')
expect(content_variables).to eq({ '1' => 'Diana' })
end
end
end
context 'with media templates' do
context 'with media template using processed_params format' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'processed_params' => {
'1' => 'https://cdn.example.com/product.jpg',
'2' => 'MacBook Pro',
'3' => '$2499'
}
}
end
it 'processes key-value parameters for media templates' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://cdn.example.com/product.jpg',
'2' => 'MacBook Pro',
'3' => '$2499'
})
end
end
context 'with media template using WhatsApp Cloud API format' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'image',
'image' => { 'link' => 'https://example.com/product-image.jpg' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Samsung Galaxy' },
{ 'type' => 'text', 'text' => '$899' }
]
}
]
}
end
it 'processes media header and body parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/product-image.jpg',
'2' => 'Samsung Galaxy',
'3' => '$899'
})
end
end
context 'with video media template' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'video',
'video' => { 'link' => 'https://example.com/demo.mp4' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Product Demo' }
]
}
]
}
end
it 'processes video media parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/demo.mp4',
'2' => 'Product Demo'
})
end
end
context 'with document media template' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'document',
'document' => { 'link' => 'https://example.com/brochure.pdf' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Product Brochure' }
]
}
]
}
end
it 'processes document media parameters correctly' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/brochure.pdf',
'2' => 'Product Brochure'
})
end
end
context 'with header parameter without media link' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{ 'type' => 'text', 'text' => 'Header Text' }
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'Body Text' }
]
}
]
}
end
it 'skips header without media and processes body parameters' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({ '1' => 'Body Text' })
end
end
context 'with mixed component types' do
let(:template_params) do
{
'name' => 'product_showcase',
'language' => 'en',
'parameters' => [
{
'type' => 'header',
'parameters' => [
{
'type' => 'image',
'image' => { 'link' => 'https://example.com/header.jpg' }
}
]
},
{
'type' => 'body',
'parameters' => [
{ 'type' => 'text', 'text' => 'First param' },
{ 'type' => 'text', 'text' => 'Second param' }
]
},
{
'type' => 'footer',
'parameters' => []
}
]
}
end
it 'processes supported components and ignores unsupported ones' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX555666777')
expect(content_variables).to eq({
'1' => 'https://example.com/header.jpg',
'2' => 'First param',
'3' => 'Second param'
})
end
end
end
context 'with language matching' do
context 'with exact language match' do
let(:template_params) do
{
'name' => 'order_status',
'language' => 'es'
}
end
it 'finds template with exact language match' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX444555666')
expect(content_variables).to eq({})
end
end
context 'with default language fallback' do
let(:template_params) do
{
'name' => 'hello_world'
# No language specified, should default to 'en'
}
end
it 'defaults to English when no language specified' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX123456789')
expect(content_variables).to eq({})
end
end
end
context 'with unapproved template status' do
let(:template_params) do
{
'name' => 'unapproved_template',
'language' => 'en'
}
end
before do
unapproved_template = {
'content_sid' => 'HX_UNAPPROVED',
'friendly_name' => 'unapproved_template',
'language' => 'en',
'status' => 'pending',
'template_type' => 'text',
'variables' => {},
'body' => 'This is unapproved'
}
updated_templates = content_templates['templates'] + [unapproved_template]
twilio_channel.update!(
content_templates: { 'templates' => updated_templates }
)
end
it 'ignores templates that are not approved' do
content_sid, content_variables = processor_service.call
expect(content_sid).to be_nil
expect(content_variables).to be_nil
end
end
context 'with unknown template type' do
let(:template_params) do
{
'name' => 'unknown_type',
'language' => 'en'
}
end
before do
unknown_template = {
'content_sid' => 'HX_UNKNOWN',
'friendly_name' => 'unknown_type',
'language' => 'en',
'status' => 'approved',
'template_type' => 'catalog',
'variables' => {},
'body' => 'Catalog template'
}
updated_templates = content_templates['templates'] + [unknown_template]
twilio_channel.update!(
content_templates: { 'templates' => updated_templates }
)
end
it 'returns empty content variables for unknown template types' do
content_sid, content_variables = processor_service.call
expect(content_sid).to eq('HX_UNKNOWN')
expect(content_variables).to eq({})
end
end
end
describe 'template finding behavior' do
context 'with no content_templates' do
let(:template_params) do
{
'name' => 'hello_world',
'language' => 'en'
}
end
before do
twilio_channel.update!(content_templates: {})
end
it 'returns nil values when content_templates is empty' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
context 'with nil content_templates' do
let(:template_params) do
{
'name' => 'hello_world',
'language' => 'en'
}
end
before do
twilio_channel.update!(content_templates: nil)
end
it 'returns nil values when content_templates is nil' do
result = processor_service.call
expect(result).to eq([nil, nil])
end
end
end
end

View File

@@ -0,0 +1,319 @@
require 'rails_helper'
RSpec.describe Twilio::TemplateSyncService do
subject(:sync_service) { described_class.new(channel: twilio_channel) }
let!(:account) { create(:account) }
let!(:twilio_channel) { create(:channel_twilio_sms, medium: :whatsapp, account: account) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:content_api) { double }
let(:contents_list) { double }
# Mock Twilio template objects
let(:text_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX123456789',
friendly_name: 'hello_world',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => 'Hello World!' } }
)
end
let(:media_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX987654321',
friendly_name: 'product_showcase',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: { '1' => 'iPhone', '2' => '$999' },
types: {
'twilio/media' => {
'body' => 'Check out {{1}} for {{2}}',
'media' => ['https://example.com/image.jpg']
}
}
)
end
let(:quick_reply_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX555666777',
friendly_name: 'welcome_message',
language: 'en_US',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/quick-reply' => {
'body' => 'Welcome! How can we help?',
'actions' => [
{ 'id' => 'support', 'title' => 'Support' },
{ 'id' => 'sales', 'title' => 'Sales' }
]
}
}
)
end
let(:catalog_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX111222333',
friendly_name: 'product_catalog',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/catalog' => {
'body' => 'Check our catalog',
'catalog_id' => 'catalog123'
}
}
)
end
let(:templates) { [text_template, media_template, quick_reply_template, catalog_template] }
before do
allow(twilio_channel).to receive(:send).and_call_original
allow(twilio_channel).to receive(:send).with(:client).and_return(twilio_client)
allow(twilio_client).to receive(:content).and_return(content_api)
allow(content_api).to receive(:v1).and_return(content_api)
allow(content_api).to receive(:contents).and_return(contents_list)
allow(contents_list).to receive(:list).with(limit: 1000).and_return(templates)
end
describe '#call' do
context 'with successful sync' do
it 'fetches templates from Twilio and updates the channel' do
freeze_time do
result = sync_service.call
expect(result).to be_truthy
expect(contents_list).to have_received(:list).with(limit: 1000)
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_last_updated).to be_within(1.second).of(Time.current)
end
end
it 'correctly formats text templates' do
sync_service.call
twilio_channel.reload
text_template_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'hello_world'
end
expect(text_template_data).to include(
'content_sid' => 'HX123456789',
'friendly_name' => 'hello_world',
'language' => 'en',
'status' => 'approved',
'template_type' => 'text',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Hello World!'
)
end
it 'correctly formats media templates' do
sync_service.call
twilio_channel.reload
media_template_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'product_showcase'
end
expect(media_template_data).to include(
'content_sid' => 'HX987654321',
'friendly_name' => 'product_showcase',
'language' => 'en',
'status' => 'approved',
'template_type' => 'media',
'media_type' => nil, # Would be derived from media content if present
'variables' => { '1' => 'iPhone', '2' => '$999' },
'category' => 'utility',
'body' => 'Check out {{1}} for {{2}}'
)
end
it 'correctly formats quick reply templates' do
sync_service.call
twilio_channel.reload
quick_reply_template_data = twilio_channel.content_templates['templates'].find do |t|
t['friendly_name'] == 'welcome_message'
end
expect(quick_reply_template_data).to include(
'content_sid' => 'HX555666777',
'friendly_name' => 'welcome_message',
'language' => 'en_US',
'status' => 'approved',
'template_type' => 'quick_reply',
'media_type' => nil,
'variables' => {},
'category' => 'utility',
'body' => 'Welcome! How can we help?'
)
end
it 'categorizes marketing templates correctly' do
marketing_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_MARKETING',
friendly_name: 'promo_offer_50_off',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => '50% off sale!' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([marketing_template])
sync_service.call
twilio_channel.reload
marketing_data = twilio_channel.content_templates['templates'].first
expect(marketing_data['category']).to eq('marketing')
end
it 'categorizes authentication templates correctly' do
auth_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_AUTH',
friendly_name: 'otp_verification',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => 'Your OTP is {{1}}' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([auth_template])
sync_service.call
twilio_channel.reload
auth_data = twilio_channel.content_templates['templates'].first
expect(auth_data['category']).to eq('authentication')
end
end
context 'with API error' do
before do
allow(contents_list).to receive(:list).and_raise(Twilio::REST::TwilioError.new('API Error'))
allow(Rails.logger).to receive(:error)
end
it 'handles Twilio::REST::TwilioError gracefully' do
result = sync_service.call
expect(result).to be_falsey
expect(Rails.logger).to have_received(:error).with('Twilio template sync failed: API Error')
end
end
context 'with generic error' do
before do
allow(contents_list).to receive(:list).and_raise(StandardError, 'Connection failed')
allow(Rails.logger).to receive(:error)
end
it 'propagates non-Twilio errors' do
expect { sync_service.call }.to raise_error(StandardError, 'Connection failed')
end
end
context 'with empty templates list' do
before do
allow(contents_list).to receive(:list).with(limit: 1000).and_return([])
end
it 'updates channel with empty templates array' do
sync_service.call
twilio_channel.reload
expect(twilio_channel.content_templates['templates']).to eq([])
expect(twilio_channel.content_templates_last_updated).to be_present
end
end
end
describe 'template categorization behavior' do
it 'defaults to utility category for unrecognized patterns' do
generic_template = instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_GENERIC',
friendly_name: 'order_status',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: { 'twilio/text' => { 'body' => 'Order updated' } }
)
allow(contents_list).to receive(:list).with(limit: 1000).and_return([generic_template])
sync_service.call
twilio_channel.reload
template_data = twilio_channel.content_templates['templates'].first
expect(template_data['category']).to eq('utility')
end
end
describe 'template type detection' do
context 'with multiple type definitions' do
let(:mixed_template) do
instance_double(
Twilio::REST::Content::V1::ContentInstance,
sid: 'HX_MIXED',
friendly_name: 'mixed_type',
language: 'en',
date_created: Time.current,
date_updated: Time.current,
variables: {},
types: {
'twilio/media' => { 'body' => 'Media content' },
'twilio/text' => { 'body' => 'Text content' }
}
)
end
before do
allow(contents_list).to receive(:list).with(limit: 1000).and_return([mixed_template])
end
it 'prioritizes media type for type detection but text for body extraction' do
sync_service.call
twilio_channel.reload
template_data = twilio_channel.content_templates['templates'].first
# derive_template_type prioritizes media
expect(template_data['template_type']).to eq('media')
# but extract_body_content prioritizes text
expect(template_data['body']).to eq('Text content')
end
end
end
end