From 7d6a43fc724ce4a5ceab6adfbf44cf157f766dea Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Sun, 24 Aug 2025 10:05:15 +0530 Subject: [PATCH] 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> --- .../api/v1/accounts/inboxes_controller.rb | 18 +- .../channels/twilio/templates_sync_job.rb | 7 + app/models/channel/twilio_sms.rb | 22 +- app/services/twilio/send_on_twilio_service.rb | 42 +- .../twilio/template_processor_service.rb | 121 ++++ app/services/twilio/template_sync_service.rb | 113 ++++ app/views/api/v1/models/_inbox.json.jbuilder | 9 +- config/features.yml | 3 + ...042_add_content_templates_to_twilio_sms.rb | 6 + db/schema.rb | 4 +- .../twilio/templates_sync_job_spec.rb | 50 ++ .../twilio/template_processor_service_spec.rb | 598 ++++++++++++++++++ .../twilio/template_sync_service_spec.rb | 319 ++++++++++ 13 files changed, 1293 insertions(+), 19 deletions(-) create mode 100644 app/jobs/channels/twilio/templates_sync_job.rb create mode 100644 app/services/twilio/template_processor_service.rb create mode 100644 app/services/twilio/template_sync_service.rb create mode 100644 db/migrate/20250822061042_add_content_templates_to_twilio_sms.rb create mode 100644 spec/jobs/channels/twilio/templates_sync_job_spec.rb create mode 100644 spec/services/twilio/template_processor_service_spec.rb create mode 100644 spec/services/twilio/template_sync_service_spec.rb diff --git a/app/controllers/api/v1/accounts/inboxes_controller.rb b/app/controllers/api/v1/accounts/inboxes_controller.rb index 78b4b9e2f..4750e3b4a 100644 --- a/app/controllers/api/v1/accounts/inboxes_controller.rb +++ b/app/controllers/api/v1/accounts/inboxes_controller.rb @@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController end def sync_templates - unless @inbox.channel.is_a?(Channel::Whatsapp) - return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } - end + return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' } unless whatsapp_channel? - Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + trigger_template_sync render status: :ok, json: { message: 'Template sync initiated successfully' } rescue StandardError => e render status: :internal_server_error, json: { error: e.message } @@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController [] end end + + def whatsapp_channel? + @inbox.whatsapp? || (@inbox.twilio? && @inbox.channel.whatsapp?) + end + + def trigger_template_sync + if @inbox.whatsapp? + Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel) + elsif @inbox.twilio? && @inbox.channel.whatsapp? + Channels::Twilio::TemplatesSyncJob.perform_later(@inbox.channel) + end + end end Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController') diff --git a/app/jobs/channels/twilio/templates_sync_job.rb b/app/jobs/channels/twilio/templates_sync_job.rb new file mode 100644 index 000000000..1c0abcbee --- /dev/null +++ b/app/jobs/channels/twilio/templates_sync_job.rb @@ -0,0 +1,7 @@ +class Channels::Twilio::TemplatesSyncJob < ApplicationJob + queue_as :low + + def perform(twilio_channel) + Twilio::TemplateSyncService.new(channel: twilio_channel).call + end +end diff --git a/app/models/channel/twilio_sms.rb b/app/models/channel/twilio_sms.rb index e4575f58a..73e5c873e 100644 --- a/app/models/channel/twilio_sms.rb +++ b/app/models/channel/twilio_sms.rb @@ -2,16 +2,18 @@ # # Table name: channel_twilio_sms # -# id :bigint not null, primary key -# account_sid :string not null -# api_key_sid :string -# auth_token :string not null -# medium :integer default("sms") -# messaging_service_sid :string -# phone_number :string -# created_at :datetime not null -# updated_at :datetime not null -# account_id :integer not null +# id :bigint not null, primary key +# account_sid :string not null +# api_key_sid :string +# auth_token :string not null +# content_templates :jsonb +# content_templates_last_updated :datetime +# medium :integer default("sms") +# messaging_service_sid :string +# phone_number :string +# created_at :datetime not null +# updated_at :datetime not null +# account_id :integer not null # # Indexes # diff --git a/app/services/twilio/send_on_twilio_service.rb b/app/services/twilio/send_on_twilio_service.rb index 950880f08..d2fe5719b 100644 --- a/app/services/twilio/send_on_twilio_service.rb +++ b/app/services/twilio/send_on_twilio_service.rb @@ -7,13 +7,53 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService def perform_reply begin - twilio_message = channel.send_message(**message_params) + twilio_message = if template_params.present? + send_template_message + else + channel.send_message(**message_params) + end rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e Messages::StatusUpdateService.new(message, 'failed', e.message).perform end message.update!(source_id: twilio_message.sid) if twilio_message end + def send_template_message + content_sid, content_variables = process_template_params + + if content_sid.blank? + message.update!(status: :failed, external_error: 'Template not found') + return nil + end + + send_params = { + to: contact_inbox.source_id, + content_sid: content_sid + } + + send_params[:content_variables] = content_variables.to_json if content_variables.present? + send_params[:status_callback] = channel.send(:twilio_delivery_status_index_url) if channel.respond_to?(:twilio_delivery_status_index_url, true) + + # Add messaging service or from number + send_params = send_params.merge(channel.send(:send_message_from)) + + channel.send(:client).messages.create(**send_params) + end + + def template_params + message.additional_attributes && message.additional_attributes['template_params'] + end + + def process_template_params + return [nil, nil] if template_params.blank? + + Twilio::TemplateProcessorService.new( + channel: channel, + template_params: template_params, + message: message + ).call + end + def message_params { body: message.outgoing_content, diff --git a/app/services/twilio/template_processor_service.rb b/app/services/twilio/template_processor_service.rb new file mode 100644 index 000000000..0c0faf9b1 --- /dev/null +++ b/app/services/twilio/template_processor_service.rb @@ -0,0 +1,121 @@ +class Twilio::TemplateProcessorService + pattr_initialize [:channel!, :template_params, :message] + + def call + return [nil, nil] if template_params.blank? + + template = find_template + return [nil, nil] if template.blank? + + content_variables = build_content_variables(template) + [template['content_sid'], content_variables] + end + + private + + def find_template + channel.content_templates&.dig('templates')&.find do |template| + template['friendly_name'] == template_params['name'] && + template['language'] == (template_params['language'] || 'en') && + template['status'] == 'approved' + end + end + + 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 'media' + convert_media_template(template_params) + else + {} + end + end + + def convert_text_template(chatwoot_params) + return process_key_value_params(chatwoot_params['processed_params']) if chatwoot_params['processed_params'].present? + + process_whatsapp_format_params(chatwoot_params['parameters']) + end + + def process_key_value_params(processed_params) + content_variables = {} + processed_params.each do |key, value| + content_variables[key.to_s] = value.to_s + end + content_variables + end + + def process_whatsapp_format_params(parameters) + content_variables = {} + parameter_index = 1 + + parameters&.each do |component| + next unless component['type'] == 'body' + + component['parameters']&.each do |param| + content_variables[parameter_index.to_s] = param['text'] + parameter_index += 1 + end + end + + content_variables + end + + def convert_media_template(chatwoot_params) + content_variables = {} + + # Handle processed_params format (key-value pairs) + if chatwoot_params['processed_params'].present? + chatwoot_params['processed_params'].each do |key, value| + content_variables[key.to_s] = value.to_s + end + else + # Handle parameters format (WhatsApp Cloud API format) + parameter_index = 1 + chatwoot_params['parameters']&.each do |component| + parameter_index = process_component(component, content_variables, parameter_index) + end + end + + content_variables + end + + def process_component(component, content_variables, parameter_index) + case component['type'] + when 'header' + process_media_header(component, content_variables, parameter_index) + when 'body' + process_body_parameters(component, content_variables, parameter_index) + else + parameter_index + end + end + + def process_media_header(component, content_variables, parameter_index) + media_param = component['parameters']&.first + return parameter_index unless media_param + + media_link = extract_media_link(media_param) + if media_link + content_variables[parameter_index.to_s] = media_link + parameter_index + 1 + else + parameter_index + end + end + + def extract_media_link(media_param) + media_param.dig('image', 'link') || + media_param.dig('video', 'link') || + media_param.dig('document', 'link') + end + + def process_body_parameters(component, content_variables, parameter_index) + component['parameters']&.each do |param| + content_variables[parameter_index.to_s] = param['text'] + parameter_index += 1 + end + parameter_index + end +end diff --git a/app/services/twilio/template_sync_service.rb b/app/services/twilio/template_sync_service.rb new file mode 100644 index 000000000..51a951404 --- /dev/null +++ b/app/services/twilio/template_sync_service.rb @@ -0,0 +1,113 @@ +class Twilio::TemplateSyncService + pattr_initialize [:channel!] + + def call + fetch_templates_from_twilio + update_channel_templates + mark_templates_updated + rescue Twilio::REST::TwilioError => e + Rails.logger.error("Twilio template sync failed: #{e.message}") + false + end + + private + + def fetch_templates_from_twilio + @templates = client.content.v1.contents.list(limit: 1000) + end + + def update_channel_templates + formatted_templates = @templates.map do |template| + { + content_sid: template.sid, + friendly_name: template.friendly_name, + language: template.language, + status: derive_status(template), + template_type: derive_template_type(template), + media_type: derive_media_type(template), + variables: template.variables || {}, + category: derive_category(template), + body: extract_body_content(template), + created_at: template.date_created, + updated_at: template.date_updated + } + end + + channel.update!( + content_templates: { templates: formatted_templates }, + content_templates_last_updated: Time.current + ) + end + + def mark_templates_updated + channel.update!(content_templates_last_updated: Time.current) + end + + def client + @client ||= channel.send(:client) + end + + def derive_status(_template) + # For now, assume all fetched templates are approved + # In the future, this could check approval status from Twilio + 'approved' + end + + def derive_template_type(template) + template_types = template.types.keys + + if template_types.include?('twilio/media') + 'media' + elsif template_types.include?('twilio/quick-reply') + 'quick_reply' + elsif template_types.include?('twilio/catalog') + 'catalog' + else + 'text' + end + end + + def derive_media_type(template) + return nil unless derive_template_type(template) == 'media' + + media_content = template.types['twilio/media'] + return nil unless media_content + + if media_content['image'] + 'image' + elsif media_content['video'] + 'video' + elsif media_content['document'] + 'document' + end + end + + def derive_category(template) + # Map template friendly names or other attributes to categories + # For now, use utility as default + case template.friendly_name + when /marketing|promo|offer|sale/i + 'marketing' + when /auth|otp|verify|code/i + 'authentication' + else + 'utility' + end + end + + def extract_body_content(template) + template_types = template.types + + if template_types['twilio/text'] + template_types['twilio/text']['body'] + elsif template_types['twilio/media'] + template_types['twilio/media']['body'] + elsif template_types['twilio/quick-reply'] + template_types['twilio/quick-reply']['body'] + elsif template_types['twilio/catalog'] + template_types['twilio/catalog']['body'] + else + '' + end + end +end diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 9c225037f..0e4122121 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -63,9 +63,12 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram? json.messaging_service_sid resource.channel.try(:messaging_service_sid) json.phone_number resource.channel.try(:phone_number) json.medium resource.channel.try(:medium) if resource.twilio? -if resource.twilio? && Current.account_user&.administrator? - json.auth_token resource.channel.try(:auth_token) - json.account_sid resource.channel.try(:account_sid) +if resource.twilio? + json.content_templates resource.channel.try(:content_templates) + if Current.account_user&.administrator? + json.auth_token resource.channel.try(:auth_token) + json.account_sid resource.channel.try(:account_sid) + end end if resource.email? diff --git a/config/features.yml b/config/features.yml index d6f2d24a3..942eda32e 100644 --- a/config/features.yml +++ b/config/features.yml @@ -195,3 +195,6 @@ display_name: Assignment V2 enabled: false chatwoot_internal: true +- name: twilio_content_templates + display_name: Twilio Content Templates + enabled: false diff --git a/db/migrate/20250822061042_add_content_templates_to_twilio_sms.rb b/db/migrate/20250822061042_add_content_templates_to_twilio_sms.rb new file mode 100644 index 000000000..35e5161db --- /dev/null +++ b/db/migrate/20250822061042_add_content_templates_to_twilio_sms.rb @@ -0,0 +1,6 @@ +class AddContentTemplatesToTwilioSms < ActiveRecord::Migration[7.1] + def change + add_column :channel_twilio_sms, :content_templates, :jsonb, default: {} + add_column :channel_twilio_sms, :content_templates_last_updated, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index 7f44c373c..798111be7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do +ActiveRecord::Schema[7.1].define(version: 2025_08_22_061042) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -474,6 +474,8 @@ ActiveRecord::Schema[7.1].define(version: 2025_08_08_123008) do t.integer "medium", default: 0 t.string "messaging_service_sid" t.string "api_key_sid" + t.jsonb "content_templates", default: {} + t.datetime "content_templates_last_updated" t.index ["account_sid", "phone_number"], name: "index_channel_twilio_sms_on_account_sid_and_phone_number", unique: true t.index ["messaging_service_sid"], name: "index_channel_twilio_sms_on_messaging_service_sid", unique: true t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true diff --git a/spec/jobs/channels/twilio/templates_sync_job_spec.rb b/spec/jobs/channels/twilio/templates_sync_job_spec.rb new file mode 100644 index 000000000..7472f0257 --- /dev/null +++ b/spec/jobs/channels/twilio/templates_sync_job_spec.rb @@ -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 diff --git a/spec/services/twilio/template_processor_service_spec.rb b/spec/services/twilio/template_processor_service_spec.rb new file mode 100644 index 000000000..260015fbb --- /dev/null +++ b/spec/services/twilio/template_processor_service_spec.rb @@ -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 diff --git a/spec/services/twilio/template_sync_service_spec.rb b/spec/services/twilio/template_sync_service_spec.rb new file mode 100644 index 000000000..26d2db0e2 --- /dev/null +++ b/spec/services/twilio/template_sync_service_spec.rb @@ -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