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| customer.ogm(params["from"]) }
292								.then do |ogm|
293									render :voicemail, locals: { ogm: ogm }
294								end
295						end
296					end
297
298					r.post do
299						true_call_id = true_inbound_call[pseudo_call_id]
300						render :bridge, locals: { call_id: true_call_id }
301					end
302				end
303
304				r.post do
305					if true_inbound_call[pseudo_call_id]
306						true_inbound_call[pseudo_call_id] = params["callId"]
307						return render :pause, locals: { duration: 300 }
308					end
309
310					customer_repo.find_by_tel(params["to"]).then do |customer|
311						CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd|
312							if fwd
313								body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
314									cc.to = fwd.to
315									cc.from = params["from"]
316									cc.application_id = params["applicationId"]
317									cc.call_timeout = fwd.timeout.to_i
318									cc.answer_url = url inbound_calls_path(nil)
319									cc.disconnect_url = url inbound_calls_path(:transfer_complete)
320								end
321								true_inbound_call[pseudo_call_id] = params["callId"]
322								outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
323									CONFIG[:creds][:account], body: body
324								).data.call_id
325								render :pause, locals: { duration: 300 }
326							else
327								render :redirect, locals: { to: inbound_calls_path(:voicemail) }
328							end
329						end
330					end
331				end
332			end
333		end
334
335		r.on "outbound" do
336			r.on "calls" do
337				r.post "status" do
338					log.info "#{params['eventType']} #{params['callId']}", loggable_params
339					if params["eventType"] == "disconnect"
340						CDR.for_outbound(params).save.catch(&method(:log_error))
341					end
342					"OK"
343				end
344
345				r.post do
346					customer_id = params["from"].sub(/^\+/, "")
347					customer_repo.find(customer_id).then(:registered?).then do |reg|
348						render :forward, locals: {
349							from: reg.phone,
350							to: params["to"]
351						}
352					end
353				end
354			end
355		end
356
357		r.public
358	end
359end
360# rubocop:enable Metrics/ClassLength