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