web.rb

  1# frozen_string_literal: true
  2
  3require "digest"
  4require "forwardable"
  5require "roda"
  6require "thin"
  7require "sentry-ruby"
  8require "bandwidth"
  9
 10Faraday.default_adapter = :em_synchrony
 11
 12require_relative "lib/cdr"
 13require_relative "lib/roda_capture"
 14require_relative "lib/roda_em_promise"
 15require_relative "lib/rack_fiber"
 16
 17BANDWIDTH_VOICE = Bandwidth::Client.new(
 18	voice_basic_auth_user_name: CONFIG[:creds][:username],
 19	voice_basic_auth_password: CONFIG[:creds][:password]
 20).voice_client.client
 21
 22module CustomerFwd
 23	def self.from_redis(redis, customer, tel)
 24		EMPromise.all([
 25			redis.get("catapult_fwd-#{tel}"),
 26			customer.fwd_timeout
 27		]).then do |(fwd, stimeout)|
 28			timeout = Timeout.new(stimeout)
 29			next if !fwd || timeout.zero?
 30			self.for(fwd, timeout)
 31		end
 32	end
 33
 34	def self.for(uri, timeout)
 35		case uri
 36		when /^tel:/
 37			Tel.new(uri, timeout)
 38		when /^sip:/
 39			SIP.new(uri, timeout)
 40		when /^xmpp:/
 41			XMPP.new(uri, timeout)
 42		else
 43			raise "Unknown forward URI: #{uri}"
 44		end
 45	end
 46
 47	class Timeout
 48		def initialize(s)
 49			@timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i
 50		end
 51
 52		def zero?
 53			@timeout.zero?
 54		end
 55
 56		def to_i
 57			@timeout
 58		end
 59	end
 60
 61	class Tel
 62		attr_reader :timeout
 63
 64		def initialize(uri, timeout)
 65			@tel = uri.sub(/^tel:/, "")
 66			@timeout = timeout
 67		end
 68
 69		def to
 70			@tel
 71		end
 72	end
 73
 74	class SIP
 75		attr_reader :timeout
 76
 77		def initialize(uri, timeout)
 78			@uri = uri
 79			@timeout = timeout
 80		end
 81
 82		def to
 83			@uri
 84		end
 85	end
 86
 87	class XMPP
 88		attr_reader :timeout
 89
 90		def initialize(uri, timeout)
 91			@jid = uri.sub(/^xmpp:/, "")
 92			@timeout = timeout
 93		end
 94
 95		def to
 96			"sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com"
 97		end
 98	end
 99end
100
101# rubocop:disable Metrics/ClassLength
102class Web < Roda
103	use Rack::Fiber # Must go first!
104	use Sentry::Rack::CaptureExceptions
105	plugin :json_parser
106	plugin :public
107	plugin :render, engine: "slim"
108	plugin RodaCapture
109	plugin RodaEMPromise # Must go last!
110
111	class << self
112		attr_reader :customer_repo, :log
113		attr_reader :true_inbound_call, :outbound_transfers
114
115		def run(log, customer_repo, *listen_on)
116			plugin :common_logger, log, method: :info
117			@customer_repo = customer_repo
118			@true_inbound_call = {}
119			@outbound_transfers = {}
120			Thin::Logging.logger = log
121			Thin::Server.start(
122				*listen_on,
123				freeze.app,
124				signals: false
125			)
126		end
127	end
128
129	extend Forwardable
130	def_delegators :'self.class', :customer_repo, :true_inbound_call,
131	               :outbound_transfers
132	def_delegators :request, :params
133
134	def log
135		opts[:common_logger]
136	end
137
138	def log_error(e)
139		log.error(
140			"Error raised during #{request.full_path}: #{e.class}",
141			e,
142			loggable_params
143		)
144		if e.is_a?(::Exception)
145			Sentry.capture_exception(e)
146		else
147			Sentry.capture_message(e.to_s)
148		end
149	end
150
151	def loggable_params
152		params.dup.tap do |p|
153			p.delete("to")
154			p.delete("from")
155		end
156	end
157
158	def pseudo_call_id
159		request.captures_hash[:pseudo_call_id] ||
160			Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
161	end
162
163	TEL_CANDIDATES = {
164		"Restricted" => "14",
165		"anonymous" => "15",
166		"Anonymous" => "16",
167		"unavailable" => "17",
168		"Unavailable" => "18"
169	}.freeze
170
171	def sanitize_tel_candidate(candidate)
172		if candidate.length < 3
173			"13;phone-context=anonymous.phone-context.soprani.ca"
174		elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
175			candidate
176		elsif candidate == "Restricted"
177			TEL_CANDIDATES.fetch(candidate, "19") +
178				";phone-context=anonymous.phone-context.soprani.ca"
179		end
180	end
181
182	def from_jid
183		Blather::JID.new(
184			sanitize_tel_candidate(params["from"]),
185			CONFIG[:component][:jid]
186		)
187	end
188
189	def inbound_calls_path(suffix)
190		["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
191	end
192
193	def url(path)
194		"#{request.base_url}#{path}"
195	end
196
197	def modify_call(call_id)
198		body = Bandwidth::ApiModifyCallRequest.new
199		yield body
200		BANDWIDTH_VOICE.modify_call(
201			CONFIG[:creds][:account],
202			call_id,
203			body: body
204		)
205	end
206
207	route do |r|
208		r.on "inbound" do
209			r.on "calls" do
210				r.post "status" do
211					if params["eventType"] == "disconnect"
212						p_call_id = pseudo_call_id
213						call_id = params["callId"]
214						EM.promise_timer(2).then {
215							next unless true_inbound_call[p_call_id] == call_id
216							true_inbound_call.delete(p_call_id)
217
218							if (outbound_leg = outbound_transfers.delete(p_call_id))
219								modify_call(outbound_leg) do |call|
220									call.state = "completed"
221								end
222							end
223
224							customer_repo.find_by_tel(params["to"]).then do |customer|
225								CDR.for_inbound(customer.customer_id, params).save
226							end
227						}.catch(&method(:log_error))
228					end
229					"OK"
230				end
231
232				r.on :pseudo_call_id do |pseudo_call_id|
233					r.post "transfer_complete" do
234						outbound_leg = outbound_transfers.delete(pseudo_call_id)
235						if params["cause"] == "hangup"
236							log.debug "Normal hangup", loggable_params
237						elsif !outbound_leg
238							log.debug "Inbound disconnected", loggable_params
239						else
240							log.debug "Go to voicemail", loggable_params
241							true_call_id = true_inbound_call[pseudo_call_id]
242							modify_call(true_call_id) do |call|
243								call.redirect_url = url inbound_calls_path(:voicemail)
244							end
245						end
246						""
247					end
248
249					r.on "voicemail" do
250						r.post "audio" do
251							duration = Time.parse(params["endTime"]) -
252							           Time.parse(params["startTime"])
253							next "OK<5" unless duration > 5
254
255							jmp_media_url = params["mediaUrl"].sub(
256								/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
257								"https://jmp.chat"
258							)
259
260							customer_repo.find_by_tel(params["to"]).then do |customer|
261								m = Blather::Stanza::Message.new
262								m.chat_state = nil
263								m.from = from_jid
264								m.subject = "New Voicemail"
265								m.body = jmp_media_url
266								m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
267								customer.stanza_to(m)
268
269								"OK"
270							end
271						end
272
273						r.post "transcription" do
274							customer_repo.find_by_tel(params["to"]).then do |customer|
275								m = Blather::Stanza::Message.new
276								m.chat_state = nil
277								m.from = from_jid
278								m.subject = "Voicemail Transcription"
279								m.body = BANDWIDTH_VOICE.get_recording_transcription(
280									params["accountId"], params["callId"], params["recordingId"]
281								).data.transcripts[0].text
282								customer.stanza_to(m)
283
284								"OK"
285							end
286						end
287
288						r.post do
289							customer_repo
290								.find_by_tel(params["to"])
291								.then { |customer|
292									EMPromise.all([
293										customer.ogm(params["from"]),
294										customer.catapult_flag(
295											BackendSgx::VOICEMAIL_TRANSCRIPTION_DISABLED
296										)
297									])
298								}.then do |(ogm, transcription_disabled)|
299									render :voicemail, locals: {
300										ogm: ogm,
301										transcription_enabled: !transcription_disabled
302									}
303								end
304						end
305					end
306
307					r.post do
308						true_call_id = true_inbound_call[pseudo_call_id]
309						render :bridge, locals: { call_id: true_call_id }
310					end
311				end
312
313				r.post do
314					if true_inbound_call[pseudo_call_id]
315						true_inbound_call[pseudo_call_id] = params["callId"]
316						return render :pause, locals: { duration: 300 }
317					end
318
319					customer_repo.find_by_tel(params["to"]).then do |customer|
320						CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd|
321							if fwd
322								body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
323									cc.to = fwd.to
324									cc.from = params["from"]
325									cc.application_id = params["applicationId"]
326									cc.call_timeout = fwd.timeout.to_i
327									cc.answer_url = url inbound_calls_path(nil)
328									cc.disconnect_url = url inbound_calls_path(:transfer_complete)
329								end
330								true_inbound_call[pseudo_call_id] = params["callId"]
331								outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
332									CONFIG[:creds][:account], body: body
333								).data.call_id
334								render :pause, locals: { duration: 300 }
335							else
336								render :redirect, locals: { to: inbound_calls_path(:voicemail) }
337							end
338						end
339					end
340				end
341			end
342		end
343
344		r.on "outbound" do
345			r.on "calls" do
346				r.post "status" do
347					log.info "#{params['eventType']} #{params['callId']}", loggable_params
348					if params["eventType"] == "disconnect"
349						CDR.for_outbound(params).save.catch(&method(:log_error))
350					end
351					"OK"
352				end
353
354				r.post do
355					customer_id = params["from"].sub(/^\+1/, "")
356					customer_repo.find(customer_id).then(:registered?).then do |reg|
357						render :forward, locals: {
358							from: reg.phone,
359							to: params["to"]
360						}
361					end
362				end
363			end
364		end
365
366		r.public
367	end
368end
369# rubocop:enable Metrics/ClassLength