Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
class Twilio::CsatTemplateApiClient
|
||||
def initialize(twilio_channel)
|
||||
@twilio_channel = twilio_channel
|
||||
end
|
||||
|
||||
def create_template(request_body)
|
||||
HTTParty.post(
|
||||
"#{api_base_path}/v1/Content",
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def submit_for_approval(approval_url, template_name, category)
|
||||
request_body = {
|
||||
name: template_name,
|
||||
category: category
|
||||
}
|
||||
|
||||
HTTParty.post(
|
||||
approval_url,
|
||||
headers: api_headers,
|
||||
body: request_body.to_json
|
||||
)
|
||||
end
|
||||
|
||||
def delete_template(content_sid)
|
||||
HTTParty.delete(
|
||||
"#{api_base_path}/v1/Content/#{content_sid}",
|
||||
headers: api_headers
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_template(content_sid)
|
||||
HTTParty.get(
|
||||
"#{api_base_path}/v1/Content/#{content_sid}",
|
||||
headers: api_headers
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_approval_status(content_sid)
|
||||
HTTParty.get(
|
||||
"#{api_base_path}/v1/Content/#{content_sid}/ApprovalRequests",
|
||||
headers: api_headers
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def api_headers
|
||||
{
|
||||
'Authorization' => "Basic #{encoded_credentials}",
|
||||
'Content-Type' => 'application/json'
|
||||
}
|
||||
end
|
||||
|
||||
def encoded_credentials
|
||||
if @twilio_channel.api_key_sid.present?
|
||||
Base64.strict_encode64("#{@twilio_channel.api_key_sid}:#{@twilio_channel.auth_token}")
|
||||
else
|
||||
Base64.strict_encode64("#{@twilio_channel.account_sid}:#{@twilio_channel.auth_token}")
|
||||
end
|
||||
end
|
||||
|
||||
def api_base_path
|
||||
'https://content.twilio.com'
|
||||
end
|
||||
end
|
||||
204
research/chatwoot/app/services/twilio/csat_template_service.rb
Normal file
204
research/chatwoot/app/services/twilio/csat_template_service.rb
Normal file
@@ -0,0 +1,204 @@
|
||||
class Twilio::CsatTemplateService
|
||||
DEFAULT_BUTTON_TEXT = 'Please rate us'.freeze
|
||||
DEFAULT_LANGUAGE = 'en'.freeze
|
||||
TEMPLATE_CATEGORY = 'UTILITY'.freeze
|
||||
TEMPLATE_STATUS_PENDING = 'PENDING'.freeze
|
||||
TEMPLATE_CONTENT_TYPE = 'twilio/call-to-action'.freeze
|
||||
|
||||
def initialize(twilio_channel)
|
||||
@twilio_channel = twilio_channel
|
||||
@api_client = Twilio::CsatTemplateApiClient.new(twilio_channel)
|
||||
end
|
||||
|
||||
def create_template(template_config)
|
||||
base_name = template_config[:template_name]
|
||||
template_name = generate_template_name(base_name)
|
||||
template_config_with_name = template_config.merge(template_name: template_name)
|
||||
|
||||
request_body = build_template_request_body(template_config_with_name)
|
||||
|
||||
# Step 1: Create template
|
||||
response = @api_client.create_template(request_body)
|
||||
|
||||
return process_template_creation_response(response, template_config_with_name) unless response.success? && response['sid']
|
||||
|
||||
# Step 2: Submit for WhatsApp approval using the approval_create URL
|
||||
approval_url = response.dig('links', 'approval_create')
|
||||
|
||||
if approval_url.present?
|
||||
approval_response = submit_for_whatsapp_approval(approval_url, template_config_with_name[:template_name])
|
||||
process_approval_response(approval_response, response, template_config_with_name)
|
||||
else
|
||||
Rails.logger.warn 'No approval_create URL provided in template creation response'
|
||||
# Fallback if no approval URL provided
|
||||
process_template_creation_response(response, template_config_with_name)
|
||||
end
|
||||
end
|
||||
|
||||
def delete_template(_template_name = nil, content_sid = nil)
|
||||
content_sid ||= current_template_sid_from_config
|
||||
return { success: false, error: 'No template to delete' } unless content_sid
|
||||
|
||||
response = @api_client.delete_template(content_sid)
|
||||
{ success: response.success?, response_body: response.body }
|
||||
end
|
||||
|
||||
def get_template_status(content_sid)
|
||||
return { success: false, error: 'No content SID provided' } unless content_sid
|
||||
|
||||
template_response = fetch_template_details(content_sid)
|
||||
return template_response unless template_response[:success]
|
||||
|
||||
approval_response = fetch_approval_status(content_sid)
|
||||
build_template_status_response(content_sid, template_response[:data], approval_response)
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Error fetching Twilio template status: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_template_details(content_sid)
|
||||
response = @api_client.fetch_template(content_sid)
|
||||
|
||||
if response.success?
|
||||
{ success: true, data: response }
|
||||
else
|
||||
Rails.logger.error "Failed to get template details: #{response.code} - #{response.body}"
|
||||
{ success: false, error: 'Template not found' }
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_approval_status(content_sid)
|
||||
@api_client.fetch_approval_status(content_sid)
|
||||
end
|
||||
|
||||
def build_template_status_response(content_sid, template_response, approval_response)
|
||||
if approval_response.success? && approval_response['whatsapp']
|
||||
build_approved_template_response(content_sid, template_response, approval_response['whatsapp'])
|
||||
else
|
||||
build_pending_template_response(content_sid, template_response)
|
||||
end
|
||||
end
|
||||
|
||||
def build_approved_template_response(content_sid, template_response, whatsapp_data)
|
||||
{
|
||||
success: true,
|
||||
template: {
|
||||
content_sid: content_sid,
|
||||
friendly_name: whatsapp_data['name'] || template_response['friendly_name'],
|
||||
status: whatsapp_data['status'] || 'pending',
|
||||
language: template_response['language'] || 'en'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def build_pending_template_response(content_sid, template_response)
|
||||
{
|
||||
success: true,
|
||||
template: {
|
||||
content_sid: content_sid,
|
||||
friendly_name: template_response['friendly_name'],
|
||||
status: 'pending',
|
||||
language: template_response['language'] || 'en'
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def generate_template_name(base_name)
|
||||
current_template_name = current_template_name_from_config
|
||||
CsatTemplateNameService.generate_next_template_name(base_name, @twilio_channel.inbox.id, current_template_name)
|
||||
end
|
||||
|
||||
def current_template_name_from_config
|
||||
@twilio_channel.inbox.csat_config&.dig('template', 'friendly_name')
|
||||
end
|
||||
|
||||
def current_template_sid_from_config
|
||||
@twilio_channel.inbox.csat_config&.dig('template', 'content_sid')
|
||||
end
|
||||
|
||||
def template_exists_in_config?
|
||||
content_sid = current_template_sid_from_config
|
||||
friendly_name = current_template_name_from_config
|
||||
|
||||
content_sid.present? && friendly_name.present?
|
||||
end
|
||||
|
||||
def build_template_request_body(template_config)
|
||||
{
|
||||
friendly_name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
variables: {
|
||||
'1' => '12345' # Example conversation UUID
|
||||
},
|
||||
types: {
|
||||
TEMPLATE_CONTENT_TYPE => {
|
||||
body: template_config[:message],
|
||||
actions: [
|
||||
{
|
||||
type: 'URL',
|
||||
title: template_config[:button_text] || DEFAULT_BUTTON_TEXT,
|
||||
url: "#{template_config[:base_url]}/survey/responses/{{1}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def submit_for_whatsapp_approval(approval_url, template_name)
|
||||
@api_client.submit_for_approval(approval_url, template_name, TEMPLATE_CATEGORY)
|
||||
end
|
||||
|
||||
def process_template_creation_response(response, template_config = {})
|
||||
if response.success? && response['sid']
|
||||
{
|
||||
success: true,
|
||||
content_sid: response['sid'],
|
||||
friendly_name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
status: TEMPLATE_STATUS_PENDING
|
||||
}
|
||||
else
|
||||
Rails.logger.error "Twilio template creation failed: #{response.code} - #{response.body}"
|
||||
{
|
||||
success: false,
|
||||
error: 'Template creation failed',
|
||||
response_body: response.body
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def process_approval_response(approval_response, creation_response, template_config)
|
||||
if approval_response.success?
|
||||
build_successful_approval_response(approval_response, creation_response, template_config)
|
||||
else
|
||||
build_failed_approval_response(approval_response, creation_response, template_config)
|
||||
end
|
||||
end
|
||||
|
||||
def build_successful_approval_response(approval_response, creation_response, template_config)
|
||||
approval_data = approval_response.parsed_response
|
||||
{
|
||||
success: true,
|
||||
content_sid: creation_response['sid'],
|
||||
friendly_name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
status: TEMPLATE_STATUS_PENDING,
|
||||
approval_sid: approval_data['sid'],
|
||||
whatsapp_status: approval_data.dig('whatsapp', 'status') || TEMPLATE_STATUS_PENDING
|
||||
}
|
||||
end
|
||||
|
||||
def build_failed_approval_response(approval_response, creation_response, template_config)
|
||||
Rails.logger.error "Twilio template approval submission failed: #{approval_response.code} - #{approval_response.body}"
|
||||
{
|
||||
success: true,
|
||||
content_sid: creation_response['sid'],
|
||||
friendly_name: template_config[:template_name],
|
||||
language: template_config[:language] || DEFAULT_LANGUAGE,
|
||||
status: 'created'
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class Twilio::DeliveryStatusService
|
||||
pattr_initialize [:params!]
|
||||
# Reference: https://www.twilio.com/docs/messaging/api/message-resource#message-status-values
|
||||
|
||||
def perform
|
||||
return if twilio_channel.blank?
|
||||
|
||||
return unless supported_status?
|
||||
|
||||
process_statuses if message.present?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_statuses
|
||||
@message.status = status
|
||||
@message.external_error = external_error if error_occurred?
|
||||
@message.save!
|
||||
end
|
||||
|
||||
def supported_status?
|
||||
%w[sent delivered read failed undelivered].include?(params[:MessageStatus])
|
||||
end
|
||||
|
||||
def status
|
||||
params[:MessageStatus] == 'undelivered' ? 'failed' : params[:MessageStatus]
|
||||
end
|
||||
|
||||
def external_error
|
||||
return nil unless error_occurred?
|
||||
|
||||
error_message = params[:ErrorMessage].presence
|
||||
error_code = params[:ErrorCode]
|
||||
|
||||
if error_message.present?
|
||||
"#{error_code} - #{error_message}"
|
||||
elsif error_code.present?
|
||||
I18n.t('conversations.messages.delivery_status.error_code', error_code: error_code)
|
||||
end
|
||||
end
|
||||
|
||||
def error_occurred?
|
||||
params[:ErrorCode].present? && %w[failed undelivered].include?(params[:MessageStatus])
|
||||
end
|
||||
|
||||
def twilio_channel
|
||||
@twilio_channel ||= if params[:MessagingServiceSid].present?
|
||||
::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid])
|
||||
elsif params[:AccountSid].present? && params[:From].present?
|
||||
::Channel::TwilioSms.find_by(account_sid: params[:AccountSid], phone_number: params[:From])
|
||||
end
|
||||
log_channel_not_found if @twilio_channel.blank?
|
||||
@twilio_channel
|
||||
end
|
||||
|
||||
def message
|
||||
return unless params[:MessageSid]
|
||||
|
||||
@message ||= twilio_channel.inbox.messages.find_by(source_id: params[:MessageSid])
|
||||
end
|
||||
|
||||
def log_channel_not_found
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Delivery status channel lookup failed ' \
|
||||
"account_sid=#{params[:AccountSid]} " \
|
||||
"from=#{params[:From]} " \
|
||||
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"message_sid=#{params[:MessageSid]}"
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,212 @@
|
||||
class Twilio::IncomingMessageService
|
||||
include ::FileTypeHelper
|
||||
|
||||
pattr_initialize [:params!]
|
||||
|
||||
def perform
|
||||
return if twilio_channel.blank?
|
||||
|
||||
set_contact
|
||||
set_conversation
|
||||
@message = @conversation.messages.build(
|
||||
content: message_body,
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @contact,
|
||||
source_id: params[:SmsSid]
|
||||
)
|
||||
attach_files
|
||||
attach_location if location_message?
|
||||
@message.save!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_channel
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(messaging_service_sid: params[:MessagingServiceSid]) if params[:MessagingServiceSid].present?
|
||||
if params[:AccountSid].present? && params[:To].present?
|
||||
@twilio_channel ||= ::Channel::TwilioSms.find_by(account_sid: params[:AccountSid],
|
||||
phone_number: params[:To])
|
||||
end
|
||||
log_channel_not_found if @twilio_channel.blank?
|
||||
@twilio_channel
|
||||
end
|
||||
|
||||
def log_channel_not_found
|
||||
Rails.logger.warn(
|
||||
'[TWILIO] Incoming message channel lookup failed ' \
|
||||
"account_sid=#{params[:AccountSid]} " \
|
||||
"to=#{params[:To]} " \
|
||||
"messaging_service_sid=#{params[:MessagingServiceSid]} " \
|
||||
"sms_sid=#{params[:SmsSid]}"
|
||||
)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= twilio_channel.inbox
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= inbox.account
|
||||
end
|
||||
|
||||
def phone_number
|
||||
twilio_channel.sms? ? params[:From] : params[:From].gsub('whatsapp:', '')
|
||||
end
|
||||
|
||||
def normalized_phone_number
|
||||
return phone_number unless twilio_channel.whatsapp?
|
||||
|
||||
Whatsapp::PhoneNumberNormalizationService.new(inbox).normalize_and_find_contact_by_provider("whatsapp:#{phone_number}", :twilio)
|
||||
end
|
||||
|
||||
def formatted_phone_number
|
||||
TelephoneNumber.parse(phone_number).international_number
|
||||
end
|
||||
|
||||
def message_body
|
||||
params[:Body]&.delete("\u0000")
|
||||
end
|
||||
|
||||
def set_contact
|
||||
source_id = twilio_channel.whatsapp? ? normalized_phone_number : params[:From]
|
||||
|
||||
contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: source_id,
|
||||
inbox: inbox,
|
||||
contact_attributes: contact_attributes
|
||||
).perform
|
||||
|
||||
@contact_inbox = contact_inbox
|
||||
@contact = contact_inbox.contact
|
||||
|
||||
# Update existing contact name if ProfileName is available and current name is just phone number
|
||||
update_contact_name_if_needed
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
account_id: @inbox.account_id,
|
||||
inbox_id: @inbox.id,
|
||||
contact_id: @contact.id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def set_conversation
|
||||
# if lock to single conversation is disabled, we will create a new conversation if previous conversation is resolved
|
||||
@conversation = if @inbox.lock_to_single_conversation
|
||||
@contact_inbox.conversations.last
|
||||
else
|
||||
@contact_inbox.conversations.where
|
||||
.not(status: :resolved).last
|
||||
end
|
||||
return if @conversation
|
||||
|
||||
@conversation = ::Conversation.create!(conversation_params)
|
||||
end
|
||||
|
||||
def contact_attributes
|
||||
{
|
||||
name: contact_name,
|
||||
phone_number: phone_number,
|
||||
additional_attributes: additional_attributes
|
||||
}
|
||||
end
|
||||
|
||||
def contact_name
|
||||
params[:ProfileName].presence || formatted_phone_number
|
||||
end
|
||||
|
||||
def additional_attributes
|
||||
if twilio_channel.sms?
|
||||
{
|
||||
from_zip_code: params[:FromZip],
|
||||
from_country: params[:FromCountry],
|
||||
from_state: params[:FromState]
|
||||
}
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def attach_files
|
||||
num_media = params[:NumMedia].to_i
|
||||
return if num_media.zero?
|
||||
|
||||
num_media.times do |i|
|
||||
media_url = params[:"MediaUrl#{i}"]
|
||||
attach_single_file(media_url) if media_url.present?
|
||||
end
|
||||
end
|
||||
|
||||
def attach_single_file(media_url)
|
||||
attachment_file = download_attachment_file(media_url)
|
||||
return if attachment_file.blank?
|
||||
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: file_type(attachment_file.content_type),
|
||||
file: {
|
||||
io: attachment_file,
|
||||
filename: attachment_file.original_filename,
|
||||
content_type: attachment_file.content_type
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def download_attachment_file(media_url)
|
||||
download_with_auth(media_url)
|
||||
rescue Down::Error, Down::ClientError => e
|
||||
handle_download_attachment_error(e, media_url)
|
||||
end
|
||||
|
||||
def download_with_auth(media_url)
|
||||
auth_credentials = if twilio_channel.api_key_sid.present?
|
||||
# When using api_key_sid, the auth token should be the api_secret_key
|
||||
[twilio_channel.api_key_sid, twilio_channel.auth_token]
|
||||
else
|
||||
# When using account_sid, the auth token is the account's auth token
|
||||
[twilio_channel.account_sid, twilio_channel.auth_token]
|
||||
end
|
||||
|
||||
Down.download(media_url, http_basic_authentication: auth_credentials)
|
||||
end
|
||||
|
||||
def handle_download_attachment_error(error, media_url)
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
|
||||
Down.download(media_url)
|
||||
rescue StandardError => e
|
||||
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
|
||||
nil
|
||||
end
|
||||
|
||||
def location_message?
|
||||
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
|
||||
end
|
||||
|
||||
def attach_location
|
||||
@message.attachments.new(
|
||||
account_id: @message.account_id,
|
||||
file_type: :location,
|
||||
coordinates_lat: params[:Latitude].to_f,
|
||||
coordinates_long: params[:Longitude].to_f
|
||||
)
|
||||
end
|
||||
|
||||
def update_contact_name_if_needed
|
||||
return if params[:ProfileName].blank?
|
||||
return if @contact.name == params[:ProfileName]
|
||||
|
||||
# Only update if current name exactly matches the phone number or formatted phone number
|
||||
return unless contact_name_matches_phone_number?
|
||||
|
||||
@contact.update!(name: params[:ProfileName])
|
||||
end
|
||||
|
||||
def contact_name_matches_phone_number?
|
||||
@contact.name == phone_number || @contact.name == formatted_phone_number
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,35 @@
|
||||
class Twilio::OneoffSmsCampaignService
|
||||
pattr_initialize [:campaign!]
|
||||
|
||||
def perform
|
||||
raise "Invalid campaign #{campaign.id}" if campaign.inbox.inbox_type != 'Twilio SMS' || !campaign.one_off?
|
||||
raise 'Completed Campaign' if campaign.completed?
|
||||
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
|
||||
audience_label_ids = campaign.audience.select { |audience| audience['type'] == 'Label' }.pluck('id')
|
||||
audience_labels = campaign.account.labels.where(id: audience_label_ids).pluck(:title)
|
||||
process_audience(audience_labels)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
delegate :inbox, to: :campaign
|
||||
delegate :channel, to: :inbox
|
||||
|
||||
def process_audience(audience_labels)
|
||||
campaign.account.contacts.tagged_with(audience_labels, any: true).each do |contact|
|
||||
next if contact.phone_number.blank?
|
||||
|
||||
content = Liquid::CampaignTemplateService.new(campaign: campaign, contact: contact).call(campaign.message)
|
||||
|
||||
begin
|
||||
channel.send_message(to: contact.phone_number, body: content)
|
||||
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
|
||||
Rails.logger.error("[Twilio Campaign #{campaign.id}] Failed to send to #{contact.phone_number}: #{e.message}")
|
||||
next
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
100
research/chatwoot/app/services/twilio/send_on_twilio_service.rb
Normal file
100
research/chatwoot/app/services/twilio/send_on_twilio_service.rb
Normal file
@@ -0,0 +1,100 @@
|
||||
class Twilio::SendOnTwilioService < Base::SendOnChannelService
|
||||
def send_csat_template_message(phone_number:, content_sid:, content_variables: {})
|
||||
send_params = {
|
||||
to: phone_number,
|
||||
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))
|
||||
|
||||
twilio_message = channel.send(:client).messages.create(**send_params)
|
||||
|
||||
{ success: true, message_id: twilio_message.sid }
|
||||
rescue Twilio::REST::TwilioError, Twilio::REST::RestError => e
|
||||
Rails.logger.error "Failed to send Twilio template message: #{e.message}"
|
||||
{ success: false, error: e.message }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def channel_class
|
||||
Channel::TwilioSms
|
||||
end
|
||||
|
||||
def perform_reply
|
||||
begin
|
||||
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,
|
||||
to: contact_inbox.source_id,
|
||||
media_url: attachments
|
||||
}
|
||||
end
|
||||
|
||||
def attachments
|
||||
message.attachments.map(&:download_url)
|
||||
end
|
||||
|
||||
def inbox
|
||||
@inbox ||= message.inbox
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def outgoing_message?
|
||||
message.outgoing? || message.template?
|
||||
end
|
||||
end
|
||||
@@ -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', 'call_to_action'
|
||||
convert_text_template(template_params) # Text, quick reply and call-to-action 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
|
||||
120
research/chatwoot/app/services/twilio/template_sync_service.rb
Normal file
120
research/chatwoot/app/services/twilio/template_sync_service.rb
Normal file
@@ -0,0 +1,120 @@
|
||||
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 { |template| format_template(template) }
|
||||
|
||||
channel.update!(
|
||||
content_templates: { templates: formatted_templates },
|
||||
content_templates_last_updated: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
def format_template(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),
|
||||
types: template.types,
|
||||
created_at: template.date_created,
|
||||
updated_at: template.date_updated
|
||||
}
|
||||
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/call-to-action')
|
||||
'call_to_action'
|
||||
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/call-to-action']
|
||||
template_types['twilio/call-to-action']['body']
|
||||
elsif template_types['twilio/catalog']
|
||||
template_types['twilio/catalog']['body']
|
||||
else
|
||||
''
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,51 @@
|
||||
class Twilio::WebhookSetupService
|
||||
include Rails.application.routes.url_helpers
|
||||
|
||||
pattr_initialize [:inbox!]
|
||||
|
||||
def perform
|
||||
if channel.messaging_service_sid?
|
||||
update_messaging_service
|
||||
else
|
||||
update_phone_number
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_messaging_service
|
||||
twilio_client
|
||||
.messaging.services(channel.messaging_service_sid)
|
||||
.update(
|
||||
inbound_method: 'POST',
|
||||
inbound_request_url: twilio_callback_index_url,
|
||||
use_inbound_webhook_on_number: false
|
||||
)
|
||||
end
|
||||
|
||||
def update_phone_number
|
||||
if phone_numbers.empty?
|
||||
Rails.logger.warn "TWILIO_PHONE_NUMBER_NOT_FOUND: #{channel.phone_number}"
|
||||
else
|
||||
twilio_client
|
||||
.incoming_phone_numbers(phonenumber_sid)
|
||||
.update(sms_method: 'POST', sms_url: twilio_callback_index_url)
|
||||
end
|
||||
end
|
||||
|
||||
def phonenumber_sid
|
||||
phone_numbers.first.sid
|
||||
end
|
||||
|
||||
def phone_numbers
|
||||
@phone_numbers ||= twilio_client.incoming_phone_numbers.list(phone_number: channel.phone_number)
|
||||
end
|
||||
|
||||
def channel
|
||||
@channel ||= inbox.channel
|
||||
end
|
||||
|
||||
def twilio_client
|
||||
@twilio_client ||= ::Twilio::REST::Client.new(channel.account_sid, channel.auth_token)
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user