web.rb

  1# frozen_string_literal: true
  2
  3require "digest"
  4require "forwardable"
  5require "roda"
  6require "thin"
  7require "sentry-ruby"
  8
  9require_relative "lib/cdr"
 10require_relative "lib/roda_capture"
 11require_relative "lib/roda_em_promise"
 12require_relative "lib/rack_fiber"
 13
 14# rubocop:disable Metrics/ClassLength
 15class Web < Roda
 16	use Rack::Fiber # Must go first!
 17	use Sentry::Rack::CaptureExceptions
 18	plugin :json_parser
 19	plugin :public
 20	plugin :render, engine: "slim"
 21	plugin RodaCapture
 22	plugin RodaEMPromise # Must go last!
 23
 24	class << self
 25		attr_reader :customer_repo, :log
 26		attr_reader :true_inbound_call, :outbound_transfers
 27
 28		def run(log, *listen_on)
 29			plugin :common_logger, log, method: :info
 30			@true_inbound_call = {}
 31			@outbound_transfers = {}
 32			Thin::Logging.logger = log
 33			Thin::Server.start(
 34				*listen_on,
 35				freeze.app,
 36				signals: false
 37			)
 38		end
 39	end
 40
 41	extend Forwardable
 42	def_delegators :'self.class', :true_inbound_call, :outbound_transfers
 43	def_delegators :request, :params
 44
 45	def log
 46		opts[:common_logger]
 47	end
 48
 49	def log_error(e)
 50		log.error(
 51			"Error raised during #{request.full_path}: #{e.class}",
 52			e,
 53			loggable_params
 54		)
 55		if e.is_a?(::Exception)
 56			Sentry.capture_exception(e)
 57		else
 58			Sentry.capture_message(e.to_s)
 59		end
 60	end
 61
 62	def loggable_params
 63		params.dup.tap do |p|
 64			p.delete("to")
 65			p.delete("from")
 66		end
 67	end
 68
 69	def pseudo_call_id
 70		request.captures_hash[:pseudo_call_id] ||
 71			Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
 72	end
 73
 74	TEL_CANDIDATES = {
 75		"Restricted" => "14",
 76		"anonymous" => "15",
 77		"Anonymous" => "16",
 78		"unavailable" => "17",
 79		"Unavailable" => "18"
 80	}.freeze
 81
 82	def sanitize_tel_candidate(candidate)
 83		if candidate.length < 3
 84			"13;phone-context=anonymous.phone-context.soprani.ca"
 85		elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
 86			candidate
 87		elsif candidate == "Restricted"
 88			TEL_CANDIDATES.fetch(candidate, "19") +
 89				";phone-context=anonymous.phone-context.soprani.ca"
 90		end
 91	end
 92
 93	def from_jid
 94		Blather::JID.new(
 95			sanitize_tel_candidate(params["from"]),
 96			CONFIG[:component][:jid]
 97		)
 98	end
 99
100	def inbound_calls_path(suffix)
101		["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
102	end
103
104	def url(path)
105		"#{request.base_url}#{path}"
106	end
107
108	def modify_call(call_id)
109		body = Bandwidth::ApiModifyCallRequest.new
110		yield body
111		BANDWIDTH_VOICE.modify_call(
112			CONFIG[:creds][:account],
113			call_id,
114			body: body
115		)
116	end
117
118	route do |r|
119		r.on "inbound" do
120			r.on "calls" do
121				r.post "status" do
122					if params["eventType"] == "disconnect"
123						p_call_id = pseudo_call_id
124						call_id = params["callId"]
125						EM.promise_timer(2).then {
126							next unless true_inbound_call[p_call_id] == call_id
127							true_inbound_call.delete(p_call_id)
128
129							if (outbound_leg = outbound_transfers.delete(p_call_id))
130								modify_call(outbound_leg) do |call|
131									call.state = "completed"
132								end
133							end
134
135							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
136								CDR.for_inbound(customer.customer_id, params).save
137							end
138						}.catch(&method(:log_error))
139					end
140					"OK"
141				end
142
143				r.on :pseudo_call_id do |pseudo_call_id|
144					r.post "transfer_complete" do
145						outbound_leg = outbound_transfers.delete(pseudo_call_id)
146						if params["cause"] == "hangup"
147							log.debug "Normal hangup", loggable_params
148						elsif !outbound_leg
149							log.debug "Inbound disconnected", loggable_params
150						else
151							log.debug "Go to voicemail", loggable_params
152							true_call_id = true_inbound_call[pseudo_call_id]
153							modify_call(true_call_id) do |call|
154								call.redirect_url = url inbound_calls_path(:voicemail)
155							end
156						end
157						""
158					end
159
160					r.on "voicemail" do
161						r.post "audio" do
162							duration = Time.parse(params["endTime"]) -
163							           Time.parse(params["startTime"])
164							next "OK<5" unless duration > 5
165
166							jmp_media_url = params["mediaUrl"].sub(
167								/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
168								"https://jmp.chat"
169							)
170
171							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
172								m = Blather::Stanza::Message.new
173								m.chat_state = nil
174								m.from = from_jid
175								m.subject = "New Voicemail"
176								m.body = jmp_media_url
177								m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
178								customer.stanza_to(m)
179
180								"OK"
181							end
182						end
183
184						r.post "transcription" do
185							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
186								m = Blather::Stanza::Message.new
187								m.chat_state = nil
188								m.from = from_jid
189								m.subject = "Voicemail Transcription"
190								m.body = BANDWIDTH_VOICE.get_recording_transcription(
191									params["accountId"], params["callId"], params["recordingId"]
192								).data.transcripts[0].text
193								customer.stanza_to(m)
194
195								"OK"
196							end
197						end
198
199						r.post do
200							CustomerRepo
201								.new(sgx_repo: Bwmsgsv2Repo.new)
202								.find_by_tel(params["to"])
203								.then do |customer|
204									render :voicemail, locals: {
205										ogm: customer.ogm(params["from"]),
206										transcription_enabled: customer.transcription_enabled
207									}
208								end
209						end
210					end
211
212					r.post do
213						true_call_id = true_inbound_call[pseudo_call_id]
214						render :bridge, locals: { call_id: true_call_id }
215					end
216				end
217
218				r.post do
219					if true_inbound_call[pseudo_call_id]
220						true_inbound_call[pseudo_call_id] = params["callId"]
221						return render :pause, locals: { duration: 300 }
222					end
223
224					CustomerRepo.new(
225						sgx_repo: Bwmsgsv2Repo.new
226					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
227						if fwd
228							body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
229								cc.to = fwd.to
230								cc.from = params["from"]
231								cc.application_id = params["applicationId"]
232								cc.call_timeout = fwd.timeout.to_i
233								cc.answer_url = url inbound_calls_path(nil)
234								cc.disconnect_url = url inbound_calls_path(:transfer_complete)
235							end
236							true_inbound_call[pseudo_call_id] = params["callId"]
237							outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
238								CONFIG[:creds][:account], body: body
239							).data.call_id
240							render :pause, locals: { duration: 300 }
241						else
242							render :redirect, locals: { to: inbound_calls_path(:voicemail) }
243						end
244					end
245				end
246			end
247		end
248
249		r.on "outbound" do
250			r.on "calls" do
251				r.post "status" do
252					log.info "#{params['eventType']} #{params['callId']}", loggable_params
253					if params["eventType"] == "disconnect"
254						CDR.for_outbound(params).save.catch(&method(:log_error))
255					end
256					"OK"
257				end
258
259				r.post do
260					customer_id = params["from"].sub(/^\+1/, "")
261					CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find(customer_id).then do |c|
262						render :forward, locals: {
263							from: c.registered?.phone,
264							to: params["to"]
265						}
266					end
267				end
268			end
269		end
270
271		r.public
272	end
273end
274# rubocop:enable Metrics/ClassLength