mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 03:27:52 +00:00
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>
320 lines
10 KiB
Ruby
320 lines
10 KiB
Ruby
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
|