~singpolyma/sgx-jmp

f1d56a9d5b29997249b4f8a75669bd06b7ce2508 — Stephen Paul Weber 9 months ago 1f77cd2 + 94766da
Merge branch 'sip-outbound'

* sip-outbound:
  Support transcription disablement option
  Port in inbound calls + voicemail
  Allow fetching fwd timeout as well
  Get OGM for a customer
  Helper to fetch customer's vcard-temp
  Make Disposition more real
  Allow constructing CDR for an inbound or outbound event
  Outbound calls from v2 SIP endpoint work and save a CDR
M .rubocop.yml => .rubocop.yml +3 -0
@@ 15,6 15,9 @@ Metrics/MethodLength:
    - test/*

Metrics/BlockLength:
  ExcludedMethods:
    - route
    - "on"
  Exclude:
    - test/*


M Gemfile => Gemfile +3 -0
@@ 3,6 3,7 @@
source "https://rubygems.org"

gem "amazing_print"
gem "bandwidth-sdk"
gem "blather", git: "https://github.com/singpolyma/blather.git", branch: "ergonomics"
gem "braintree"
gem "dhall"


@@ 14,9 15,11 @@ gem "em_promise.rb", "~> 0.0.3"
gem "eventmachine"
gem "money-open-exchange-rates"
gem "ougai"
gem "roda"
gem "ruby-bandwidth-iris"
gem "sentry-ruby", "<= 4.3.1"
gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite"
gem "thin"
gem "value_semantics", git: "https://github.com/singpolyma/value_semantics"

group(:development) do

M config-schema.dhall => config-schema.dhall +1 -0
@@ 43,6 43,7 @@
, sgx : Text
, sip_host : Text
, upstream_domain : Text
, web : < Inet : { interface : Text, port : Natural } | Unix : Text >
, web_register : { from : Text, to : Text }
, xep0157 : List { label : Text, value : Text, var : Text }
}

M config.dhall.sample => config.dhall.sample +3 -0
@@ 1,3 1,5 @@
let ListenOn = < Inet: { interface: Text, port: Natural } | Unix: Text >
in
{
	component = {
		jid = "component.localhost",


@@ 8,6 10,7 @@
		port = 5347
	},
	sgx = "component2.localhost",
	web = ListenOn.Inet { interface = "::1", port = env:PORT ? 8080 },
	creds = {
		account = "00000",
		username = "dashboard user",

M lib/backend_sgx.rb => lib/backend_sgx.rb +17 -0
@@ 1,6 1,8 @@
# frozen_string_literal: true

class BackendSgx
	VOICEMAIL_TRANSCRIPTION_DISABLED = 0

	def initialize(customer_id, jid=CONFIG[:sgx], creds=CONFIG[:creds])
		@customer_id = customer_id
		@jid = jid


@@ 33,6 35,21 @@ class BackendSgx
		end
	end

	def ogm_url
		REDIS.get("catapult_ogm_url-#{from_jid}")
	end

	def catapult_flag(flagbit)
		REDIS.getbit(
			"catapult_setting_flags-#{from_jid}",
			flagbit
		).then { |x| x != 1 }
	end

	def fwd_timeout
		REDIS.get("catapult_fwd_timeout-#{from_jid}")
	end

	def set_fwd_timeout(timeout)
		REDIS.set("catapult_fwd_timeout-#{from_jid}", timeout)
	end

A lib/cdr.rb => lib/cdr.rb +71 -0
@@ 0,0 1,71 @@
# frozen_string_literal: true

require "value_semantics/monkey_patched"

class CDR
	module Disposition
		def self.===(other)
			["NO ANSWER", "ANSWERED", "BUSY", "FAILED"].include?(other)
		end

		def self.for(cause)
			case cause
			when "timeout", "rejected", "cancel"
				"NO ANSWER"
			when "hangup"
				"ANSWERED"
			when "busy"
				"BUSY"
			else
				"FAILED"
			end
		end
	end

	value_semantics do
		cdr_id String
		customer_id String
		start Time
		billsec Integer
		disposition Disposition
		tel(/\A\+\d+\Z/)
		direction Either(:inbound, :outbound)
	end

	def self.for(event, **kwargs)
		start = Time.parse(event["startTime"])

		new({
			cdr_id: "sgx-jmp/#{event['callId']}",
			start: start,
			billsec: (Time.parse(event["endTime"]) - start).ceil,
			disposition: Disposition.for(event["cause"])
		}.merge(kwargs))
	end

	def self.for_inbound(customer_id, event)
		self.for(
			event,
			customer_id: customer_id,
			tel: event["from"],
			direction: :inbound
		)
	end

	def self.for_outbound(event)
		self.for(
			event,
			customer_id: event["from"].sub(/^\+/, ""),
			tel: event["to"],
			direction: :outbound
		)
	end

	def save
		columns, values = to_h.to_a.transpose
		DB.query_defer(<<~SQL, values)
			INSERT INTO cdr (#{columns.join(',')})
			VALUES ($1, $2, $3, $4, $5, $6, $7)
		SQL
	end
end

M lib/customer.rb => lib/customer.rb +16 -2
@@ 5,6 5,7 @@ require "forwardable"
require_relative "./api"
require_relative "./blather_ext"
require_relative "./customer_info"
require_relative "./customer_ogm"
require_relative "./customer_plan"
require_relative "./customer_usage"
require_relative "./backend_sgx"


@@ 20,7 21,8 @@ class Customer
	attr_reader :customer_id, :balance, :jid
	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
	               :currency, :merchant_account, :plan_name, :auto_top_up_amount
	def_delegators :@sgx, :register!, :registered?, :set_fwd_timeout
	def_delegators :@sgx, :register!, :registered?,
	               :fwd_timeout, :set_fwd_timeout, :catapult_flag
	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage

	def initialize(


@@ 67,13 69,25 @@ class Customer
		stanza = stanza.dup
		stanza.to = jid.with(resource: stanza.to&.resource)
		stanza.from = stanza.from.with(domain: CONFIG[:component][:jid])
		BLATHER << stanza
		block_given? ? yield(stanza) : (BLATHER << stanza)
	end

	def stanza_from(stanza)
		BLATHER << @sgx.stanza(stanza)
	end

	def fetch_vcard_temp(from_tel=nil)
		iq = Blather::Stanza::Iq::Vcard.new(:get)
		iq.from = Blather::JID.new(from_tel, CONFIG[:component][:jid])
		stanza_to(iq, &IQ_MANAGER.method(:write)).then(&:vcard)
	end

	def ogm(from_tel=nil)
		@sgx.ogm_url.then do |url|
			CustomerOGM.for(url, -> { fetch_vcard_temp(from_tel) })
		end
	end

	def sip_account
		SipAccount.find(customer_id)
	end

A lib/customer_ogm.rb => lib/customer_ogm.rb +44 -0
@@ 0,0 1,44 @@
# frozen_string_literal: true

module CustomerOGM
	def self.for(url, fetch_vcard_temp)
		return Media.new(url) if url
		TTS.for(fetch_vcard_temp)
	end

	class Media
		def initialize(url)
			@url = url
		end

		def to_render
			[:voicemail_ogm_media, locals: { url: @url }]
		end
	end

	class TTS
		def self.for(fetch_vcard_temp)
			fetch_vcard_temp.call.then { |vcard|
				new(vcard)
			}.catch { new(Blather::Stanza::Iq::Vcard::Vcard.new) }
		end

		def initialize(vcard)
			@vcard = vcard
		end

		def [](k)
			value = @vcard[k]
			return if value.to_s.empty?
			value
		end

		def fn
			self["FN"] || self["NICKNAME"] || "a user of JMP.chat"
		end

		def to_render
			[:voicemail_ogm_tts, locals: { fn: fn }]
		end
	end
end

A lib/rack_fiber.rb => lib/rack_fiber.rb +29 -0
@@ 0,0 1,29 @@
# frozen_string_literal: true

require "fiber"

module Rack
	class Fiber
		def initialize(app)
			@app = app
		end

		def call(env)
			async_callback = env.delete("async.callback")
			EM.next_tick { run_fiber(env, async_callback) }
			throw :async
		end

	protected

		def run_fiber(env, async_callback)
			::Fiber.new {
				begin
					async_callback.call(@app.call(env))
				rescue ::Exception # rubocop:disable Lint/RescueException
					async_callback.call([500, {}, [$!.to_s]])
				end
			}.resume
		end
	end
end

A lib/roda_capture.rb => lib/roda_capture.rb +37 -0
@@ 0,0 1,37 @@
# frozen-string-literal: true

# Build from official params_capturing plugin
class RodaCapture
	module RequestMethods
		def captures_hash
			@captures_hash ||= {}
		end

	private

		# Add the symbol to the list of capture names if capturing
		def _match_symbol(sym)
			@_sym_captures << sym if @_sym_captures

			super
		end

		# If all arguments are strings or symbols, turn on param capturing during
		# the matching, but turn it back off before yielding to the block.	Add
		# any captures to the params based on the param capture names added by
		# the matchers.
		def if_match(args)
			@_sym_captures = [] if args.all? { |x| x.is_a?(Symbol) }

			super do |*a|
				if @_sym_captures
					@_sym_captures.zip(a).each do |k, v|
						captures_hash[k] = v
					end
					@_sym_captures = nil
				end
				yield(*a)
			end
		end
	end
end

A lib/roda_em_promise.rb => lib/roda_em_promise.rb +11 -0
@@ 0,0 1,11 @@
# frozen_string_literal: true

require "em_promise"

module RodaEMPromise
	module RequestMethods
		def block_result(result)
			super(EMPromise.resolve(result).sync)
		end
	end
end

A public/beep.mp3 => public/beep.mp3 +0 -0
M sgx_jmp.rb => sgx_jmp.rb +9 -0
@@ 52,6 52,12 @@ CONFIG =
		"(#{ARGV[0]}) : #{__dir__}/config-schema.dhall",
		transform_keys: ->(k) { k&.to_sym }
	)
WEB_LISTEN =
	if CONFIG[:web].is_a?(Hash)
		[CONFIG[:web][:interface], CONFIG[:web][:port]]
	else
		[CONFIG[:web]]
	end

singleton_class.class_eval do
	include Blather::DSL


@@ 80,6 86,7 @@ require_relative "lib/transaction"
require_relative "lib/tel_selections"
require_relative "lib/session_manager"
require_relative "lib/statsd"
require_relative "web"

ELECTRUM = Electrum.new(**CONFIG[:electrum])
EM::Hiredis::Client.load_scripts_from("./redis_lua")


@@ 189,6 196,8 @@ when_ready do
		ping.from = CONFIG[:component][:jid]
		self << ping
	end

	Web.run(LOG.child, CustomerRepo.new, *WEB_LISTEN)
end

# workqueue_count MUST be 0 or else Blather uses threads!

A test/test_cdr.rb => test/test_cdr.rb +42 -0
@@ 0,0 1,42 @@
# frozen_string_literal: true

require "test_helper"
require "cdr"

class CDRTest < Minitest::Test
	def test_for_inbound
		cdr = CDR.for_inbound(
			"test",
			"from" => "+15551234567",
			"startTime" => "2020-01-01T00:00:00Z",
			"endTime" => "2020-01-01T01:00:00Z",
			"callId" => "a_call",
			"cause" => "hangup"
		)
		assert_equal cdr.cdr_id, "sgx-jmp/a_call"
		assert_equal cdr.customer_id, "test"
		assert_equal cdr.start, Time.parse("2020-01-01T00:00:00Z")
		assert_equal cdr.billsec, 60 * 60
		assert_equal cdr.disposition, "ANSWERED"
		assert_equal cdr.tel, "+15551234567"
		assert_equal cdr.direction, :inbound
	end

	def test_for_outbound
		cdr = CDR.for_outbound(
			"to" => "+15551234567",
			"from" => "+test",
			"startTime" => "2020-01-01T00:00:00Z",
			"endTime" => "2020-01-01T01:00:00Z",
			"callId" => "a_call",
			"cause" => "hangup"
		)
		assert_equal cdr.cdr_id, "sgx-jmp/a_call"
		assert_equal cdr.customer_id, "test"
		assert_equal cdr.start, Time.parse("2020-01-01T00:00:00Z")
		assert_equal cdr.billsec, 60 * 60
		assert_equal cdr.disposition, "ANSWERED"
		assert_equal cdr.tel, "+15551234567"
		assert_equal cdr.direction, :outbound
	end
end

M test/test_customer.rb => test/test_customer.rb +13 -0
@@ 8,6 8,7 @@ Customer::BRAINTREE = Minitest::Mock.new
Customer::ELECTRUM = Minitest::Mock.new
Customer::REDIS = Minitest::Mock.new
Customer::DB = Minitest::Mock.new
Customer::IQ_MANAGER = Minitest::Mock.new
CustomerPlan::DB = Minitest::Mock.new
CustomerUsage::REDIS = Minitest::Mock.new
CustomerUsage::DB = Minitest::Mock.new


@@ 109,6 110,18 @@ class CustomerTest < Minitest::Test
		Customer::BLATHER.verify
	end

	def test_fetch_vcard_temp
		result = Blather::Stanza::Iq::Vcard.new(:result)
		result.vcard["FN"] = "name"
		Customer::IQ_MANAGER.expect(
			:method,
			->(*) { EMPromise.resolve(result) },
			[:write]
		)
		assert_equal "name", customer.fetch_vcard_temp("+15551234567").sync["FN"]
	end
	em :test_fetch_vcard_temp

	def test_customer_usage_report
		report_for = (Date.today..(Date.today - 1))
		report_for.first.downto(report_for.last).each.with_index do |day, idx|

A test/test_customer_ogm.rb => test/test_customer_ogm.rb +49 -0
@@ 0,0 1,49 @@
# frozen_string_literal: true

require "test_helper"
require "customer_ogm"

class CustomerOGMTest < Minitest::Test
	def test_for_url
		assert_kind_of(
			CustomerOGM::Media,
			CustomerOGM.for("https://example.com/test.mp3", -> {})
		)
	end

	def test_for_no_url
		assert_kind_of(
			CustomerOGM::TTS,
			CustomerOGM.for(nil, -> { EMPromise.resolve(nil) }).sync
		)
	end
	em :test_for_no_url

	class TTSTest < Minitest::Test
		def test_to_render_empty_vcard
			vcard = Blather::Stanza::Iq::Vcard::Vcard.new
			assert_equal(
				[:voicemail_ogm_tts, locals: { fn: "a user of JMP.chat" }],
				CustomerOGM::TTS.new(vcard).to_render
			)
		end

		def test_to_render_fn
			vcard = Blather::Stanza::Iq::Vcard::Vcard.new
			vcard["FN"] = "name"
			assert_equal(
				[:voicemail_ogm_tts, locals: { fn: "name" }],
				CustomerOGM::TTS.new(vcard).to_render
			)
		end

		def test_to_render_nickname
			vcard = Blather::Stanza::Iq::Vcard::Vcard.new
			vcard["NICKNAME"] = "name"
			assert_equal(
				[:voicemail_ogm_tts, locals: { fn: "name" }],
				CustomerOGM::TTS.new(vcard).to_render
			)
		end
	end
end

A views/bridge.slim => views/bridge.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	Bridge= call_id

A views/forward.slim => views/forward.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	Forward from=from to=to /

A views/pause.slim => views/pause.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	Pause duration=duration

A views/redirect.slim => views/redirect.slim +3 -0
@@ 0,0 1,3 @@
doctype xml
Response
	Redirect redirectUrl=to /

A views/voicemail.slim => views/voicemail.slim +10 -0
@@ 0,0 1,10 @@
doctype xml
Response
	Pause duration=2
	== render(*ogm.to_render)
	PlayAudio= "/beep.mp3"
	Record{
		transcribe=transcription_enabled.to_s
		recordingAvailableUrl="/inbound/calls/#{pseudo_call_id}/voicemail/audio"
		transcriptionAvailableUrl="/inbound/calls/#{pseudo_call_id}/voicemail/transcription"
		fileFormat="mp3"} /

A views/voicemail_ogm_media.slim => views/voicemail_ogm_media.slim +1 -0
@@ 0,0 1,1 @@
PlayAudio= url

A views/voicemail_ogm_tts.slim => views/voicemail_ogm_tts.slim +4 -0
@@ 0,0 1,4 @@
SpeakSentence
	' You have reached the voicemail of
	= fn
	| . Please send a text message, or leave a message after the tone.

A web.rb => web.rb +369 -0
@@ 0,0 1,369 @@
# frozen_string_literal: true

require "digest"
require "forwardable"
require "roda"
require "thin"
require "sentry-ruby"
require "bandwidth"

Faraday.default_adapter = :em_synchrony

require_relative "lib/cdr"
require_relative "lib/roda_capture"
require_relative "lib/roda_em_promise"
require_relative "lib/rack_fiber"

BANDWIDTH_VOICE = Bandwidth::Client.new(
	voice_basic_auth_user_name: CONFIG[:creds][:username],
	voice_basic_auth_password: CONFIG[:creds][:password]
).voice_client.client

module CustomerFwd
	def self.from_redis(redis, customer, tel)
		EMPromise.all([
			redis.get("catapult_fwd-#{tel}"),
			customer.fwd_timeout
		]).then do |(fwd, stimeout)|
			timeout = Timeout.new(stimeout)
			next if !fwd || timeout.zero?
			self.for(fwd, timeout)
		end
	end

	def self.for(uri, timeout)
		case uri
		when /^tel:/
			Tel.new(uri, timeout)
		when /^sip:/
			SIP.new(uri, timeout)
		when /^xmpp:/
			XMPP.new(uri, timeout)
		else
			raise "Unknown forward URI: #{uri}"
		end
	end

	class Timeout
		def initialize(s)
			@timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i
		end

		def zero?
			@timeout.zero?
		end

		def to_i
			@timeout
		end
	end

	class Tel
		attr_reader :timeout

		def initialize(uri, timeout)
			@tel = uri.sub(/^tel:/, "")
			@timeout = timeout
		end

		def to
			@tel
		end
	end

	class SIP
		attr_reader :timeout

		def initialize(uri, timeout)
			@uri = uri
			@timeout = timeout
		end

		def to
			@uri
		end
	end

	class XMPP
		attr_reader :timeout

		def initialize(uri, timeout)
			@jid = uri.sub(/^xmpp:/, "")
			@timeout = timeout
		end

		def to
			"sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com"
		end
	end
end

# rubocop:disable Metrics/ClassLength
class Web < Roda
	use Rack::Fiber # Must go first!
	use Sentry::Rack::CaptureExceptions
	plugin :json_parser
	plugin :public
	plugin :render, engine: "slim"
	plugin RodaCapture
	plugin RodaEMPromise # Must go last!

	class << self
		attr_reader :customer_repo, :log
		attr_reader :true_inbound_call, :outbound_transfers

		def run(log, customer_repo, *listen_on)
			plugin :common_logger, log, method: :info
			@customer_repo = customer_repo
			@true_inbound_call = {}
			@outbound_transfers = {}
			Thin::Logging.logger = log
			Thin::Server.start(
				*listen_on,
				freeze.app,
				signals: false
			)
		end
	end

	extend Forwardable
	def_delegators :'self.class', :customer_repo, :true_inbound_call,
	               :outbound_transfers
	def_delegators :request, :params

	def log
		opts[:common_logger]
	end

	def log_error(e)
		log.error(
			"Error raised during #{request.full_path}: #{e.class}",
			e,
			loggable_params
		)
		if e.is_a?(::Exception)
			Sentry.capture_exception(e)
		else
			Sentry.capture_message(e.to_s)
		end
	end

	def loggable_params
		params.dup.tap do |p|
			p.delete("to")
			p.delete("from")
		end
	end

	def pseudo_call_id
		request.captures_hash[:pseudo_call_id] ||
			Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
	end

	TEL_CANDIDATES = {
		"Restricted" => "14",
		"anonymous" => "15",
		"Anonymous" => "16",
		"unavailable" => "17",
		"Unavailable" => "18"
	}.freeze

	def sanitize_tel_candidate(candidate)
		if candidate.length < 3
			"13;phone-context=anonymous.phone-context.soprani.ca"
		elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
			candidate
		elsif candidate == "Restricted"
			TEL_CANDIDATES.fetch(candidate, "19") +
				";phone-context=anonymous.phone-context.soprani.ca"
		end
	end

	def from_jid
		Blather::JID.new(
			sanitize_tel_candidate(params["from"]),
			CONFIG[:component][:jid]
		)
	end

	def inbound_calls_path(suffix)
		["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
	end

	def url(path)
		"#{request.base_url}#{path}"
	end

	def modify_call(call_id)
		body = Bandwidth::ApiModifyCallRequest.new
		yield body
		BANDWIDTH_VOICE.modify_call(
			CONFIG[:creds][:account],
			call_id,
			body: body
		)
	end

	route do |r|
		r.on "inbound" do
			r.on "calls" do
				r.post "status" do
					if params["eventType"] == "disconnect"
						p_call_id = pseudo_call_id
						call_id = params["callId"]
						EM.promise_timer(2).then {
							next unless true_inbound_call[p_call_id] == call_id
							true_inbound_call.delete(p_call_id)

							if (outbound_leg = outbound_transfers.delete(p_call_id))
								modify_call(outbound_leg) do |call|
									call.state = "completed"
								end
							end

							customer_repo.find_by_tel(params["to"]).then do |customer|
								CDR.for_inbound(customer.customer_id, params).save
							end
						}.catch(&method(:log_error))
					end
					"OK"
				end

				r.on :pseudo_call_id do |pseudo_call_id|
					r.post "transfer_complete" do
						outbound_leg = outbound_transfers.delete(pseudo_call_id)
						if params["cause"] == "hangup"
							log.debug "Normal hangup", loggable_params
						elsif !outbound_leg
							log.debug "Inbound disconnected", loggable_params
						else
							log.debug "Go to voicemail", loggable_params
							true_call_id = true_inbound_call[pseudo_call_id]
							modify_call(true_call_id) do |call|
								call.redirect_url = url inbound_calls_path(:voicemail)
							end
						end
						""
					end

					r.on "voicemail" do
						r.post "audio" do
							duration = Time.parse(params["endTime"]) -
							           Time.parse(params["startTime"])
							next "OK<5" unless duration > 5

							jmp_media_url = params["mediaUrl"].sub(
								/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
								"https://jmp.chat"
							)

							customer_repo.find_by_tel(params["to"]).then do |customer|
								m = Blather::Stanza::Message.new
								m.chat_state = nil
								m.from = from_jid
								m.subject = "New Voicemail"
								m.body = jmp_media_url
								m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
								customer.stanza_to(m)

								"OK"
							end
						end

						r.post "transcription" do
							customer_repo.find_by_tel(params["to"]).then do |customer|
								m = Blather::Stanza::Message.new
								m.chat_state = nil
								m.from = from_jid
								m.subject = "Voicemail Transcription"
								m.body = BANDWIDTH_VOICE.get_recording_transcription(
									params["accountId"], params["callId"], params["recordingId"]
								).data.transcripts[0].text
								customer.stanza_to(m)

								"OK"
							end
						end

						r.post do
							customer_repo
								.find_by_tel(params["to"])
								.then { |customer|
									EMPromise.all([
										customer.ogm(params["from"]),
										customer.catapult_flag(
											BackendSgx::VOICEMAIL_TRANSCRIPTION_DISABLED
										)
									])
								}.then do |(ogm, transcription_disabled)|
									render :voicemail, locals: {
										ogm: ogm,
										transcription_enabled: !transcription_disabled
									}
								end
						end
					end

					r.post do
						true_call_id = true_inbound_call[pseudo_call_id]
						render :bridge, locals: { call_id: true_call_id }
					end
				end

				r.post do
					if true_inbound_call[pseudo_call_id]
						true_inbound_call[pseudo_call_id] = params["callId"]
						return render :pause, locals: { duration: 300 }
					end

					customer_repo.find_by_tel(params["to"]).then do |customer|
						CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd|
							if fwd
								body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
									cc.to = fwd.to
									cc.from = params["from"]
									cc.application_id = params["applicationId"]
									cc.call_timeout = fwd.timeout.to_i
									cc.answer_url = url inbound_calls_path(nil)
									cc.disconnect_url = url inbound_calls_path(:transfer_complete)
								end
								true_inbound_call[pseudo_call_id] = params["callId"]
								outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
									CONFIG[:creds][:account], body: body
								).data.call_id
								render :pause, locals: { duration: 300 }
							else
								render :redirect, locals: { to: inbound_calls_path(:voicemail) }
							end
						end
					end
				end
			end
		end

		r.on "outbound" do
			r.on "calls" do
				r.post "status" do
					log.info "#{params['eventType']} #{params['callId']}", loggable_params
					if params["eventType"] == "disconnect"
						CDR.for_outbound(params).save.catch(&method(:log_error))
					end
					"OK"
				end

				r.post do
					customer_id = params["from"].sub(/^\+/, "")
					customer_repo.find(customer_id).then(:registered?).then do |reg|
						render :forward, locals: {
							from: reg.phone,
							to: params["to"]
						}
					end
				end
			end
		end

		r.public
	end
end
# rubocop:enable Metrics/ClassLength