mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 19:48:08 +00:00
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:
@@ -70,11 +70,9 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def sync_templates
|
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' } unless whatsapp_channel?
|
||||||
return render status: :unprocessable_entity, json: { error: 'Template sync is only available for WhatsApp channels' }
|
|
||||||
end
|
|
||||||
|
|
||||||
Channels::Whatsapp::TemplatesSyncJob.perform_later(@inbox.channel)
|
trigger_template_sync
|
||||||
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
render status: :ok, json: { message: 'Template sync initiated successfully' }
|
||||||
rescue StandardError => e
|
rescue StandardError => e
|
||||||
render status: :internal_server_error, json: { error: e.message }
|
render status: :internal_server_error, json: { error: e.message }
|
||||||
@@ -185,6 +183,18 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
|||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
||||||
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
Api::V1::Accounts::InboxesController.prepend_mod_with('Api::V1::Accounts::InboxesController')
|
||||||
|
|||||||
7
app/jobs/channels/twilio/templates_sync_job.rb
Normal file
7
app/jobs/channels/twilio/templates_sync_job.rb
Normal file
@@ -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
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
# account_sid :string not null
|
# account_sid :string not null
|
||||||
# api_key_sid :string
|
# api_key_sid :string
|
||||||
# auth_token :string not null
|
# auth_token :string not null
|
||||||
|
# content_templates :jsonb
|
||||||
|
# content_templates_last_updated :datetime
|
||||||
# medium :integer default("sms")
|
# medium :integer default("sms")
|
||||||
# messaging_service_sid :string
|
# messaging_service_sid :string
|
||||||
# phone_number :string
|
# phone_number :string
|
||||||
|
|||||||
@@ -7,13 +7,53 @@ class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
|||||||
|
|
||||||
def perform_reply
|
def perform_reply
|
||||||
begin
|
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
|
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
|
||||||
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
Messages::StatusUpdateService.new(message, 'failed', e.message).perform
|
||||||
end
|
end
|
||||||
message.update!(source_id: twilio_message.sid) if twilio_message
|
message.update!(source_id: twilio_message.sid) if twilio_message
|
||||||
end
|
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
|
def message_params
|
||||||
{
|
{
|
||||||
body: message.outgoing_content,
|
body: message.outgoing_content,
|
||||||
|
|||||||
121
app/services/twilio/template_processor_service.rb
Normal file
121
app/services/twilio/template_processor_service.rb
Normal file
@@ -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
|
||||||
113
app/services/twilio/template_sync_service.rb
Normal file
113
app/services/twilio/template_sync_service.rb
Normal file
@@ -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
|
||||||
@@ -63,10 +63,13 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
|||||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||||
json.phone_number resource.channel.try(:phone_number)
|
json.phone_number resource.channel.try(:phone_number)
|
||||||
json.medium resource.channel.try(:medium) if resource.twilio?
|
json.medium resource.channel.try(:medium) if resource.twilio?
|
||||||
if resource.twilio? && Current.account_user&.administrator?
|
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.auth_token resource.channel.try(:auth_token)
|
||||||
json.account_sid resource.channel.try(:account_sid)
|
json.account_sid resource.channel.try(:account_sid)
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
if resource.email?
|
if resource.email?
|
||||||
## Email Channel Attributes
|
## Email Channel Attributes
|
||||||
|
|||||||
@@ -195,3 +195,6 @@
|
|||||||
display_name: Assignment V2
|
display_name: Assignment V2
|
||||||
enabled: false
|
enabled: false
|
||||||
chatwoot_internal: true
|
chatwoot_internal: true
|
||||||
|
- name: twilio_content_templates
|
||||||
|
display_name: Twilio Content Templates
|
||||||
|
enabled: false
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
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.integer "medium", default: 0
|
||||||
t.string "messaging_service_sid"
|
t.string "messaging_service_sid"
|
||||||
t.string "api_key_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 ["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 ["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
|
t.index ["phone_number"], name: "index_channel_twilio_sms_on_phone_number", unique: true
|
||||||
|
|||||||
50
spec/jobs/channels/twilio/templates_sync_job_spec.rb
Normal file
50
spec/jobs/channels/twilio/templates_sync_job_spec.rb
Normal 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
|
||||||
598
spec/services/twilio/template_processor_service_spec.rb
Normal file
598
spec/services/twilio/template_processor_service_spec.rb
Normal 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
|
||||||
319
spec/services/twilio/template_sync_service_spec.rb
Normal file
319
spec/services/twilio/template_sync_service_spec.rb
Normal 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
|
||||||
Reference in New Issue
Block a user