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 # 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, :true_inbound_call, :outbound_transfers
 59
 60		def run(log, *listen_on)
 61			plugin :common_logger, log, method: :info
 62			@true_inbound_call = {}
 63			@outbound_transfers = {}
 64			Thin::Logging.logger = log
 65			Thin::Server.start(
 66				*listen_on,
 67				freeze.app,
 68				signals: false
 69			)
 70		end
 71	end
 72
 73	extend Forwardable
 74	def_delegators :'self.class', :true_inbound_call, :outbound_transfers
 75	def_delegators :request, :params
 76
 77	def log
 78		opts[:common_logger]
 79	end
 80
 81	def log_error(e)
 82		log.error(
 83			"Error raised during #{request.full_path}: #{e.class}",
 84			e,
 85			loggable_params
 86		)
 87		if e.is_a?(::Exception)
 88			Sentry.capture_exception(e)
 89		else
 90			Sentry.capture_message(e.to_s)
 91		end
 92	end
 93
 94	def loggable_params
 95		params.dup.tap do |p|
 96			p.delete("to")
 97			p.delete("from")
 98		end
 99	end
100
101	def pseudo_call_id
102		request.captures_hash[:pseudo_call_id] ||
103			Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
104	end
105
106	TEL_CANDIDATES = {
107		"Restricted" => "14",
108		"anonymous" => "15",
109		"Anonymous" => "16",
110		"unavailable" => "17",
111		"Unavailable" => "18"
112	}.freeze
113
114	def sanitize_tel_candidate(candidate)
115		if candidate.length < 3
116			"13;phone-context=anonymous.phone-context.soprani.ca"
117		elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
118			candidate
119		elsif candidate == "Restricted"
120			"#{TEL_CANDIDATES.fetch(candidate, '19')}" \
121				";phone-context=anonymous.phone-context.soprani.ca"
122		end
123	end
124
125	def from_jid
126		Blather::JID.new(
127			sanitize_tel_candidate(params["from"]),
128			CONFIG[:component][:jid]
129		)
130	end
131
132	def inbound_calls_path(suffix)
133		["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
134	end
135
136	def url(path)
137		"#{request.base_url}#{path}"
138	end
139
140	def modify_call(call_id)
141		body = Bandwidth::ApiModifyCallRequest.new
142		yield body
143		BANDWIDTH_VOICE.modify_call(
144			CONFIG[:creds][:account],
145			call_id,
146			body: body
147		)
148	end
149
150	route do |r|
151		r.on "inbound" do
152			r.on "calls" do
153				r.post "status" do
154					if params["eventType"] == "disconnect"
155						p_call_id = pseudo_call_id
156						call_id = params["callId"]
157						EM.promise_timer(2).then {
158							next unless true_inbound_call[p_call_id] == call_id
159
160							true_inbound_call.delete(p_call_id)
161
162							if (outbound_leg = outbound_transfers.delete(p_call_id))
163								modify_call(outbound_leg) do |call|
164									call.state = "completed"
165								end
166							end
167
168							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
169								CDR.for_inbound(customer.customer_id, params).save
170							end
171						}.catch(&method(:log_error))
172					end
173					"OK"
174				end
175
176				r.on :pseudo_call_id do |pseudo_call_id|
177					r.post "transfer_complete" do
178						outbound_leg = outbound_transfers.delete(pseudo_call_id)
179						if params["cause"] == "hangup"
180							log.debug "Normal hangup", loggable_params
181						elsif !outbound_leg
182							log.debug "Inbound disconnected", loggable_params
183						else
184							log.debug "Go to voicemail", loggable_params
185							true_call_id = true_inbound_call[pseudo_call_id]
186							modify_call(true_call_id) do |call|
187								call.redirect_url = url inbound_calls_path(:voicemail)
188							end
189						end
190						""
191					end
192
193					r.on "voicemail" do
194						r.post "audio" do
195							duration = Time.parse(params["endTime"]) -
196							           Time.parse(params["startTime"])
197							next "OK<5" unless duration > 5
198
199							jmp_media_url = params["mediaUrl"].sub(
200								/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
201								"https://jmp.chat"
202							)
203
204							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
205								m = Blather::Stanza::Message.new
206								m.chat_state = nil
207								m.from = from_jid
208								m.subject = "New Voicemail"
209								m.body = jmp_media_url
210								m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
211								customer.stanza_to(m)
212
213								"OK"
214							end
215						end
216
217						r.post "transcription" do
218							CustomerRepo.new.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							CustomerRepo
234								.new(sgx_repo: Bwmsgsv2Repo.new)
235								.find_by_tel(params["to"])
236								.then do |customer|
237									render :voicemail, locals: {
238										ogm: customer.ogm(params["from"]),
239										transcription_enabled: customer.transcription_enabled
240									}
241								end
242						end
243					end
244
245					r.post do
246						true_call_id = true_inbound_call[pseudo_call_id]
247						render :bridge, locals: { call_id: true_call_id }
248					end
249				end
250
251				r.post do
252					if true_inbound_call[pseudo_call_id]
253						true_inbound_call[pseudo_call_id] = params["callId"]
254						return render :pause, locals: { duration: 300 }
255					end
256
257					CustomerRepo.new(
258						sgx_repo: Bwmsgsv2Repo.new
259					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
260						call = fwd.create_call(CONFIG[:creds][:account]) { |cc|
261							true_inbound_call[pseudo_call_id] = params["callId"]
262							cc.from = params["from"]
263							cc.application_id = params["applicationId"]
264							cc.answer_url = url inbound_calls_path(nil)
265							cc.disconnect_url = url inbound_calls_path(:transfer_complete)
266						}
267
268						if call
269							outbound_transfers[pseudo_call_id] = call
270							render :pause, locals: { duration: 300 }
271						else
272							render :redirect, locals: { to: inbound_calls_path(:voicemail) }
273						end
274					end
275				end
276			end
277		end
278
279		r.on "outbound" do
280			r.on "calls" do
281				r.post "status" do
282					log.info "#{params['eventType']} #{params['callId']}", loggable_params
283					if params["eventType"] == "disconnect"
284						CDR.for_outbound(params).save.catch(&method(:log_error))
285					end
286					"OK"
287				end
288
289				r.post do
290					customer_id = params["from"].sub(/^\+1/, "")
291					CustomerRepo.new(
292						sgx_repo: Bwmsgsv2Repo.new
293					).find(customer_id).then do |c|
294						render :forward, locals: {
295							from: c.registered?.phone,
296							to: params["to"]
297						}
298					end
299				end
300			end
301		end
302
303		r.on "ogm" do
304			r.post "start" do
305				render :record_ogm, locals: { customer_id: params["customer_id"] }
306			end
307
308			r.post do
309				jmp_media_url = params["mediaUrl"].sub(
310					/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
311					"https://jmp.chat"
312				)
313				ogm = OGMDownload.new(jmp_media_url)
314				ogm.download.then do
315					File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
316					File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
317					CustomerRepo.new.find(params["customer_id"]).then do |customer|
318						customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
319					end
320				end
321			end
322		end
323
324		r.public
325	end
326end
327# rubocop:enable Metrics/ClassLength