lib/telephony/pinpoint/sms_sender.rb
# 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 endend