Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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

View 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', '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

View 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

View File

@@ -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