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 :type_routing
 53	plugin :public
 54	plugin :render, engine: "slim"
 55	plugin RodaCapture
 56	plugin RodaEMPromise # Must go last!
 57
 58	class << self
 59		attr_reader :customer_repo, :log, :outbound_transfers
 60
 61		def run(log, *listen_on)
 62			plugin :common_logger, log, method: :info
 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', :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 customer_repo(**kwargs)
102		opts[:customer_repo] || CustomerRepo.new(**kwargs)
103	end
104
105	TEL_CANDIDATES = {
106		"Restricted" => "14",
107		"anonymous" => "15",
108		"Anonymous" => "16",
109		"unavailable" => "17",
110		"Unavailable" => "18"
111	}.freeze
112
113	def sanitize_tel_candidate(candidate)
114		if candidate.length < 3
115			"13;phone-context=anonymous.phone-context.soprani.ca"
116		elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
117			candidate
118		else
119			"#{TEL_CANDIDATES.fetch(candidate, '19')}" \
120				";phone-context=anonymous.phone-context.soprani.ca"
121		end
122	end
123
124	def from_jid
125		Blather::JID.new(
126			sanitize_tel_candidate(params["from"]),
127			CONFIG[:component][:jid]
128		)
129	end
130
131	def inbound_calls_path(suffix)
132		["/inbound/calls/#{params['callId']}", suffix].compact.join("/")
133	end
134
135	def url(path)
136		"#{request.base_url}#{path}"
137	end
138
139	def modify_call(call_id)
140		body = Bandwidth::ApiModifyCallRequest.new
141		yield body
142		BANDWIDTH_VOICE.modify_call(
143			CONFIG[:creds][:account],
144			call_id,
145			body: body
146		)
147	end
148
149	route do |r|
150		r.on "inbound" do
151			r.on "calls" do
152				r.post "status" do
153					if params["eventType"] == "disconnect"
154						if (outbound_leg = outbound_transfers.delete(params["callId"]))
155							modify_call(outbound_leg) do |call|
156								call.state = "completed"
157							end
158						end
159
160						customer_repo.find_by_tel(params["to"]).then do |customer|
161							CDR.for_inbound(customer.customer_id, params).save
162						end
163					end
164					"OK"
165				end
166
167				r.on :call_id do |call_id|
168					r.post "transfer_complete" do
169						outbound_leg = outbound_transfers.delete(call_id)
170						if params["cause"] == "hangup"
171							log.info "Normal hangup, now end #{call_id}", loggable_params
172							modify_call(call_id) { |call| call.state = "completed" }
173						elsif !outbound_leg
174							log.debug "Inbound disconnected", loggable_params
175						else
176							log.debug "Go to voicemail", loggable_params
177							modify_call(call_id) do |call|
178								call.redirect_url = url inbound_calls_path(:voicemail)
179							end
180						end
181						""
182					end
183
184					r.on "voicemail" do
185						r.post "audio" do
186							duration = Time.parse(params["endTime"]) -
187							           Time.parse(params["startTime"])
188							next "OK<5" unless duration > 5
189
190							jmp_media_url = params["mediaUrl"].sub(
191								/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
192								"https://jmp.chat"
193							)
194
195							customer_repo.find_by_tel(params["to"]).then do |customer|
196								m = Blather::Stanza::Message.new
197								m.chat_state = nil
198								m.from = from_jid
199								m.subject = "New Voicemail"
200								m.body = jmp_media_url
201								m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
202								customer.stanza_to(m)
203
204								"OK"
205							end
206						end
207
208						r.post "transcription" do
209							duration = Time.parse(params["endTime"]) -
210							           Time.parse(params["startTime"])
211							next "OK<5" unless duration > 5
212
213							customer_repo.find_by_tel(params["to"]).then do |customer|
214								m = Blather::Stanza::Message.new
215								m.chat_state = nil
216								m.from = from_jid
217								m.subject = "Voicemail Transcription"
218								m.body = BANDWIDTH_VOICE.get_recording_transcription(
219									params["accountId"], params["callId"], params["recordingId"]
220								).data.transcripts[0].text
221								customer.stanza_to(m)
222
223								"OK"
224							end
225						end
226
227						r.post do
228							customer_repo(sgx_repo: Bwmsgsv2Repo.new)
229								.find_by_tel(params["to"])
230								.then { |c|
231									EMPromise.all([c, c.ogm(params["from"])])
232								}.then do |(customer, ogm)|
233									render :voicemail, locals: {
234										ogm: ogm,
235										transcription_enabled: customer.transcription_enabled
236									}
237								end
238						end
239					end
240
241					r.post do
242						render :bridge, locals: { call_id: call_id }
243					end
244				end
245
246				r.post do
247					customer_repo(
248						sgx_repo: Bwmsgsv2Repo.new
249					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
250						call = fwd.create_call(CONFIG[:creds][:account]) { |cc|
251							cc.from = params["from"]
252							cc.application_id = params["applicationId"]
253							cc.answer_url = url inbound_calls_path(nil)
254							cc.disconnect_url = url inbound_calls_path(:transfer_complete)
255						}
256
257						if call
258							outbound_transfers[params["callId"]] = call
259							render :ring, locals: { duration: 300 }
260						else
261							render :redirect, locals: { to: inbound_calls_path(:voicemail) }
262						end
263					end
264				end
265			end
266		end
267
268		r.on "outbound" do
269			r.on "calls" do
270				r.post "status" do
271					log.info "#{params['eventType']} #{params['callId']}", loggable_params
272					if params["eventType"] == "disconnect"
273						CDR.for_outbound(params).save.catch(&method(:log_error))
274					end
275					"OK"
276				end
277
278				r.post do
279					from = params["from"].sub(/^\+1/, "")
280					customer_repo(
281						sgx_repo: Bwmsgsv2Repo.new
282					).find_by_format(from).then do |c|
283						r.json do
284							{
285								from: c.registered?.phone,
286								to: params["to"],
287								customer_id: c.customer_id
288							}.to_json
289						end
290
291						render :forward, locals: {
292							from: c.registered?.phone,
293							to: params["to"]
294						}
295					end
296				end
297			end
298		end
299
300		r.on "ogm" do
301			r.post "start" do
302				render :record_ogm, locals: { customer_id: params["customer_id"] }
303			end
304
305			r.post do
306				jmp_media_url = params["mediaUrl"].sub(
307					/\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
308					"https://jmp.chat"
309				)
310				ogm = OGMDownload.new(jmp_media_url)
311				ogm.download.then do
312					File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
313					File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
314					customer_repo.find(params["customer_id"]).then do |customer|
315						customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
316					end
317				end
318			end
319		end
320
321		r.public
322	end
323end
324# rubocop:enable Metrics/ClassLength