18F/identity-idp

View on GitHub
lib/telephony/pinpoint/sms_sender.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
# frozen_string_literal: true
 
require 'time'
 
module Telephony
module Pinpoint
class SmsSender
ERROR_HASH = {
'DUPLICATE' => DuplicateEndpointError,
'OPT_OUT' => OptOutError,
'PERMANENT_FAILURE' => PermanentFailureError,
'TEMPORARY_FAILURE' => TemporaryFailureError,
'THROTTLED' => RateLimitedError,
'TIMEOUT' => TimeoutError,
'UNKNOWN_FAILURE' => UnknownFailureError,
}.freeze
 
# One connection pool per config (aka per-region)
# @param [Hash<PinpointSmsConfig, ConnectionPool<Aws::Pinpoint::Client>>]
# rubocop:disable Style/MutableConstant
CLIENT_POOL = Hash.new do |h, sms_config|
h[sms_config] = ConnectionPool.new(size: IdentityConfig.store.pinpoint_voice_pool_size) do
credentials = AwsCredentialBuilder.new(sms_config).call
 
Aws::Pinpoint::Client.new(
region: sms_config.region,
retry_limit: 0,
credentials: credentials,
instance_profile_credentials_timeout: 1, # defaults to 1 second
instance_profile_credentials_retries: 5, # defaults to 0 retries
)
end
end
# rubocop:enable Style/MutableConstant
 
# rubocop:disable Metrics/BlockLength
# rubocop:disable Lint/UnusedMethodArgument
# @return [Response]
def deliver(message:, to:, country_code:, otp: nil)
if Telephony.config.pinpoint.sms_configs.empty?
return PinpointHelper.handle_config_failure(:sms)
end
 
response = nil
sender_id = Telephony.config.country_sender_ids[country_code.to_s]
Telephony.config.pinpoint.sms_configs.each do |sms_config|
start = Time.zone.now
CLIENT_POOL[sms_config].with do |client|
sender_config = build_sender_config(country_code, sms_config, sender_id)
 
pinpoint_response = client.send_messages(
application_id: sms_config.application_id,
message_request: {
addresses: {
to => {
channel_type: 'SMS',
},
},
message_configuration: {
sms_message: {
body: message,
message_type: 'TRANSACTIONAL',
}.merge(sender_config),
},
},
)
finish = Time.zone.now
response = build_response(pinpoint_response, start: start, finish: finish)
if response.success? ||
response.error.is_a?(OptOutError) ||
response.error.is_a?(PermanentFailureError)
return response
end
PinpointHelper.notify_pinpoint_failover(
error: response.error,
region: sms_config.region,
channel: :sms,
extra: response.extra,
)
rescue Aws::Pinpoint::Errors::ServiceError,
Seahorse::Client::NetworkingError => e
finish = Time.zone.now
response = handle_pinpoint_error(e)
PinpointHelper.notify_pinpoint_failover(
error: e,
region: sms_config.region,
channel: :sms,
extra: {
duration_ms: Util.duration_ms(start: start, finish: finish),
},
)
end
end
response || PinpointHelper.handle_config_failure(:sms)
end
# rubocop:enable Lint/UnusedMethodArgument
# rubocop:enable Metrics/BlockLength
 
def phone_info(phone_number)
if Telephony.config.pinpoint.sms_configs.empty?
return PinpointHelper.handle_config_failure(:sms)
end
 
response = nil
error = nil
 
Telephony.config.pinpoint.sms_configs.each do |sms_config|
error = nil
CLIENT_POOL[sms_config].with do |client|
response = client.phone_number_validate(
number_validate_request: { phone_number: phone_number },
)
rescue Seahorse::Client::NetworkingError,
Aws::Pinpoint::Errors::ServiceError => error
PinpointHelper.notify_pinpoint_failover(
error: error,
region: sms_config.region,
channel: :sms,
extra: {},
)
end
break if response
end
 
type = case response&.number_validate_response&.phone_type
when 'MOBILE'
:mobile
when 'LANDLINE'
:landline
when 'VOIP'
:voip
else
:unknown
end
 
error ||= unknown_failure_error if !response
 
PhoneNumberInfo.new(
type: type,
carrier: response&.number_validate_response&.carrier,
error: error,
)
end
 
def origination_number(country_code, sms_config)
if sms_config.country_code_longcode_pool&.dig(country_code).present?
sms_config.country_code_longcode_pool[country_code].sample
elsif sms_config.country_code_shortcodes&.dig(country_code).present?
sms_config.country_code_shortcodes[country_code]
else
sms_config.shortcode
end
end
 
# If we are sending with Sender ID, we should not include origination_number
def build_sender_config(country_code, sms_config, sender_id)
if sender_id
{
sender_id: sender_id,
}
else
{
origination_number: origination_number(country_code, sms_config),
}
end
end
 
private
 
def handle_pinpoint_error(err)
error_message = "#{err.class}: #{err.message}"
 
Response.new(
success: false, error: Telephony::TelephonyError.new(error_message),
)
end
 
def build_response(pinpoint_response, start:, finish:)
message_response_result = pinpoint_response.message_response.result.values.first
 
Response.new(
success: success?(message_response_result),
error: error(message_response_result),
extra: {
request_id: pinpoint_response.message_response.request_id,
delivery_status: message_response_result.delivery_status,
message_id: message_response_result.message_id,
status_code: message_response_result.status_code,
status_message: message_response_result.status_message.gsub(/\d/, 'x'),
duration_ms: Util.duration_ms(start: start, finish: finish),
},
)
end
 
def success?(message_response_result)
message_response_result.delivery_status == 'SUCCESSFUL'
end
 
def error(message_response_result)
return nil if success?(message_response_result)
 
status_code = message_response_result.status_code
delivery_status = message_response_result.delivery_status
exception_message = "Pinpoint Error: #{delivery_status} - #{status_code}"
exception_class =
if permanent_failure_opt_out?(message_response_result)
OptOutError
else
ERROR_HASH[delivery_status] || TelephonyError
end
exception_class.new(exception_message)
end
 
# Sometimes AWS Pinpoint returns PERMANENT_FAILURE with an "opted out" message
# instead of an OPT_OUT error
# @param [Aws::Pinpoint::Types::MessageResult] message_response_result
def permanent_failure_opt_out?(message_response_result)
message_response_result.delivery_status == 'PERMANENT_FAILURE' &&
message_response_result.status_message&.include?('opted out')
end
 
def unknown_failure_error
UnknownFailureError.new('Failed to load AWS config')
end
end
end
end