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:
@@ -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,
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user