web.rb

  1# frozen_string_literal: true
  2
  3require "digest"
  4require "forwardable"
  5require "multibases"
  6require "multihashes"
  7require "roda"
  8require "thin"
  9require "sentry-ruby"
 10
 11require_relative "lib/call_attempt_repo"
 12require_relative "lib/cdr"
 13require_relative "lib/oob"
 14require_relative "lib/roda_capture"
 15require_relative "lib/roda_em_promise"
 16require_relative "lib/rack_fiber"
 17
 18class OGMDownload
 19	def initialize(url)
 20		@digest = Digest::SHA512.new
 21		@f = Tempfile.open("ogm")
 22		@req = EM::HttpRequest.new(url, tls: { verify_peer: true })
 23	end
 24
 25	def download
 26		http = @req.aget
 27		http.stream do |chunk|
 28			@digest << chunk
 29			@f.write chunk
 30		end
 31		http.then { @f.close }.catch do |e|
 32			@f.close!
 33			EMPromise.reject(e)
 34		end
 35	end
 36
 37	def cid
 38		Multibases.encode(
 39			"base58btc",
 40			[1, 85].pack("C*") + Multihashes.encode(@digest.digest, "sha2-512")
 41		).pack.to_s
 42	end
 43
 44	def path
 45		@f.path
 46	end
 47end
 48
 49# rubocop:disable Metrics/ClassLength
 50class Web < Roda
 51	use Rack::Fiber unless ENV["ENV"] == "test" # Must go first!
 52	use Sentry::Rack::CaptureExceptions
 53	plugin :json_parser
 54	plugin :type_routing
 55	plugin :public
 56	plugin :render, engine: "slim"
 57	plugin RodaCapture
 58	plugin RodaEMPromise # Must go last!
 59
 60	class << self
 61		attr_reader :customer_repo, :log, :outbound_transfers
 62
 63		def run(log, *listen_on)
 64			plugin :common_logger, log, method: :info
 65			@outbound_transfers = {}
 66			Thin::Logging.logger = log
 67			Thin::Server.start(
 68				*listen_on,
 69				freeze.app,
 70				signals: false
 71			)
 72		end
 73	end
 74
 75	extend Forwardable
 76	def_delegators :'self.class', :outbound_transfers
 77	def_delegators :request, :params
 78
 79	def log
 80		opts[:common_logger]
 81	end
 82
 83	def log_error(e)
 84		log.error(
 85			"Error raised during #{request.fullpath}: #{e.class}",
 86			e,
 87			loggable_params
 88		)
 89		if e.is_a?(::Exception)
 90			Sentry.capture_exception(e)
 91		else
 92			Sentry.capture_message(e.to_s)
 93		end
 94	end
 95
 96	def loggable_params
 97		params.dup.tap do |p|
 98			p.delete("to")
 99			p.delete("from")
100		end
101	end
102
103	def customer_repo(**kwargs)
104		opts[:customer_repo] || CustomerRepo.new(**kwargs)
105	end
106
107	def call_attempt_repo
108		opts[:call_attempt_repo] || CallAttemptRepo.new
109	end
110
111	TEL_CANDIDATES = {
112		"Restricted" => "14",
113		"anonymous" => "15",
114		"Anonymous" => "16",
115		"unavailable" => "17",
116		"Unavailable" => "18"
117	}.freeze
118
119	def sanitize_tel_candidate(candidate)
120		if candidate.length < 3
121			"13;phone-context=anonymous.phone-context.soprani.ca"
122		elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
123			candidate
124		else
125			"#{TEL_CANDIDATES.fetch(candidate, '19')}" \
126				";phone-context=anonymous.phone-context.soprani.ca"
127		end
128	end
129
130	def from_jid
131		Blather::JID.new(
132			sanitize_tel_candidate(params["from"]),
133			CONFIG[:component][:jid]
134		)
135	end
136
137	def inbound_calls_path(suffix, customer_id=nil)
138		["/inbound/calls/#{params['callId']}", suffix].compact.join("/") +
139			(customer_id ? "?customer_id=#{customer_id}" : "")
140	end
141
142	def url(path)
143		"#{request.base_url}#{path}"
144	end
145
146	def modify_call(call_id)
147		body = Bandwidth::ApiModifyCallRequest.new
148		yield body
149		BANDWIDTH_VOICE.modify_call(
150			CONFIG[:creds][:account],
151			call_id,
152			body: body
153		)
154	rescue Bandwidth::ApiErrorResponseException
155		# If call does not exist, don't need to hang up or send to voicemail
156		# Other side must have hung up already
157		raise $! unless $!.response_code.to_s == "404"
158	end
159
160	route do |r|
161		r.on "inbound" do
162			r.on "calls" do
163				r.post "status" do
164					if params["eventType"] == "disconnect"
165						if (outbound_leg = outbound_transfers.delete(params["callId"]))
166							modify_call(outbound_leg) do |call|
167								call.state = "completed"
168							end
169						end
170
171						customer_repo.find_by_tel(params["to"]).then do |customer|
172							Sentry.set_user(id: customer.customer_id)
173							CDR.for_inbound(customer.customer_id, params).save
174						end
175					end
176					"OK"
177				end
178
179				r.on :call_id do |call_id|
180					r.post "transfer_complete" do
181						outbound_leg = outbound_transfers.delete(call_id)
182						if params["cause"] == "hangup" && params["tag"] == "connected"
183							log.info "Normal hangup, now end #{call_id}", loggable_params
184							modify_call(call_id) { |call| call.state = "completed" }
185						elsif !outbound_leg
186							log.debug "Inbound disconnected", loggable_params
187						else
188							log.debug "Go to voicemail", loggable_params
189							modify_call(call_id) do |call|
190								call.redirect_url = url inbound_calls_path(:voicemail)
191							end
192						end
193						""
194					end
195
196					r.on "voicemail" do
197						r.post "audio" do
198							duration = Time.parse(params["endTime"]) -
199							           Time.parse(params["startTime"])
200							next "OK<5" unless duration > 5
201
202							jmp_media_url = params["mediaUrl"].sub(
203								/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
204								"https://jmp.chat"
205							)
206
207							customer_repo.find_by_tel(params["to"]).then do |customer|
208								Sentry.set_user(id: customer.customer_id)
209								m = Blather::Stanza::Message.new
210								m.chat_state = nil
211								m.from = from_jid
212								m.subject = "New Voicemail"
213								m.body = jmp_media_url
214								m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
215								customer.stanza_to(m)
216
217								"OK"
218							end
219						end
220
221						r.post "transcription" do
222							duration = Time.parse(params["endTime"]) -
223							           Time.parse(params["startTime"])
224							next "OK<5" unless duration > 5
225
226							customer_repo.find_by_tel(params["to"]).then do |customer|
227								Sentry.set_user(id: customer.customer_id)
228								m = Blather::Stanza::Message.new
229								m.chat_state = nil
230								m.from = from_jid
231								m.subject = "Voicemail Transcription"
232								m.body = BANDWIDTH_VOICE.get_recording_transcription(
233									params["accountId"], params["callId"], params["recordingId"]
234								).data.transcripts[0].text
235								customer.stanza_to(m)
236
237								"OK"
238							end
239						end
240
241						r.post do
242							customer_repo(sgx_repo: Bwmsgsv2Repo.new)
243								.find_by_tel(params["to"])
244								.then { |c|
245									Sentry.set_user(id: c.customer_id)
246									EMPromise.all([c, c.ogm(params["from"])])
247								}.then do |(customer, ogm)|
248									render :voicemail, locals: {
249										ogm: ogm,
250										transcription_enabled: customer.transcription_enabled
251									}
252								end
253						end
254					end
255
256					r.post do
257						customer_repo(
258							sgx_repo: Bwmsgsv2Repo.new
259						).find(params.fetch("customer_id")).then do |customer|
260							Sentry.set_user(id: customer.customer_id)
261							call_attempt_repo.find_inbound(
262								customer,
263								params["from"],
264								call_id: call_id,
265								digits: params["digits"]
266							).then { |ca| render(*ca.to_render) }
267						end
268					end
269				end
270
271				r.post do
272					Sentry.set_user(tel: params["to"])
273					customer_repo(
274						sgx_repo: Bwmsgsv2Repo.new
275					).find_by_tel(params["to"]).then { |customer|
276						Sentry.set_user(id: customer.customer_id)
277						EMPromise.all([
278							customer.customer_id, customer.fwd,
279							call_attempt_repo.find_inbound(
280								customer, params["from"], call_id: params["callId"]
281							)
282						])
283					}.then do |(customer_id, fwd, ca)|
284						call = ca.create_call(fwd, CONFIG[:creds][:account]) { |cc|
285							cc.from = params["from"]
286							cc.application_id = params["applicationId"]
287							cc.answer_url = url inbound_calls_path(nil, customer_id)
288							cc.disconnect_url = url inbound_calls_path(:transfer_complete)
289						}
290
291						if call
292							outbound_transfers[params["callId"]] = call
293							render :ring, locals: { duration: 300 }
294						else
295							render :redirect, locals: { to: inbound_calls_path(:voicemail) }
296						end
297					end
298				end
299			end
300		end
301
302		r.on "outbound" do
303			r.on "calls" do
304				r.post "status" do
305					log.info "#{params['eventType']} #{params['callId']}", loggable_params
306					if params["eventType"] == "disconnect"
307						CDR.for_outbound(params).save.catch(&method(:log_error))
308					end
309					"OK"
310				end
311
312				r.post do
313					from = params["from"].sub(/^\+1/, "")
314					customer_repo(
315						sgx_repo: Bwmsgsv2Repo.new
316					).find_by_format(from).then do |c|
317						Sentry.set_user(id: c.customer_id)
318						call_attempt_repo.find_outbound(
319							c,
320							params["to"],
321							call_id: params["callId"],
322							digits: params["digits"]
323						).then do |ca|
324							r.json { ca.to_json }
325
326							render(*ca.to_render)
327						end
328					end
329				end
330			end
331		end
332
333		r.on "ogm" do
334			r.post "start" do
335				render :record_ogm, locals: { customer_id: params["customer_id"] }
336			end
337
338			r.post do
339				jmp_media_url = params["mediaUrl"].sub(
340					/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
341					"https://jmp.chat"
342				)
343				ogm = OGMDownload.new(jmp_media_url)
344				ogm.download.then do
345					File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
346					File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
347					customer_repo.find(params["customer_id"]).then do |customer|
348						customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
349					end
350				end
351			end
352		end
353
354		r.public
355	end
356end
357# rubocop:enable Metrics/ClassLength