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