Merge branch 'sip-outbound'

Stephen Paul Weber created

* 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

Change summary

.rubocop.yml                   |   3 
Gemfile                        |   3 
config-schema.dhall            |   1 
config.dhall.sample            |   3 
lib/backend_sgx.rb             |  17 +
lib/cdr.rb                     |  71 ++++++
lib/customer.rb                |  18 +
lib/customer_ogm.rb            |  44 ++++
lib/rack_fiber.rb              |  29 ++
lib/roda_capture.rb            |  37 +++
lib/roda_em_promise.rb         |  11 +
public/beep.mp3                |   0 
sgx_jmp.rb                     |   9 
test/test_cdr.rb               |  42 ++++
test/test_customer.rb          |  13 +
test/test_customer_ogm.rb      |  49 ++++
views/bridge.slim              |   3 
views/forward.slim             |   3 
views/pause.slim               |   3 
views/redirect.slim            |   3 
views/voicemail.slim           |  10 
views/voicemail_ogm_media.slim |   1 
views/voicemail_ogm_tts.slim   |   4 
web.rb                         | 369 ++++++++++++++++++++++++++++++++++++
24 files changed, 744 insertions(+), 2 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -15,6 +15,9 @@ Metrics/MethodLength:
     - test/*
 
 Metrics/BlockLength:
+  ExcludedMethods:
+    - route
+    - "on"
   Exclude:
     - test/*
 

Gemfile 🔗

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

config-schema.dhall 🔗

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

config.dhall.sample 🔗

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

lib/backend_sgx.rb 🔗

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

lib/cdr.rb 🔗

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

lib/customer.rb 🔗

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

lib/customer_ogm.rb 🔗

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

lib/rack_fiber.rb 🔗

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

lib/roda_capture.rb 🔗

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

lib/roda_em_promise.rb 🔗

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

sgx_jmp.rb 🔗

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

test/test_cdr.rb 🔗

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

test/test_customer.rb 🔗

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

test/test_customer_ogm.rb 🔗

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

views/voicemail.slim 🔗

@@ -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"} /

views/voicemail_ogm_tts.slim 🔗

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

web.rb 🔗

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