# frozen_string_literal: true require "digest" require "forwardable" require "multibases" require "multihashes" require "roda" require "thin" require "sentry-ruby" require_relative "lib/call_attempt_repo" require_relative "lib/cdr" require_relative "lib/oob" require_relative "lib/roda_capture" require_relative "lib/roda_em_promise" require_relative "lib/rack_fiber" class OGMDownload def initialize(url) @digest = Digest::SHA512.new @f = Tempfile.open("ogm") @req = EM::HttpRequest.new(url, tls: { verify_peer: true }) end def download http = @req.aget http.stream do |chunk| @digest << chunk @f.write chunk end http.then { @f.close }.catch do |e| @f.close! EMPromise.reject(e) end end def cid Multibases.encode( "base58btc", [1, 85].pack("C*") + Multihashes.encode(@digest.digest, "sha2-512") ).pack.to_s end def path @f.path end end # rubocop:disable Metrics/ClassLength class Web < Roda use Rack::Fiber unless ENV["ENV"] == "test" # Must go first! use Sentry::Rack::CaptureExceptions plugin :json_parser plugin :type_routing plugin :public plugin :render, engine: "slim" plugin RodaCapture plugin RodaEMPromise # Must go last! class << self attr_reader :customer_repo, :log, :outbound_transfers def run(log, *listen_on) plugin :common_logger, log, method: :info @outbound_transfers = {} Thin::Logging.logger = log Thin::Server.start( *listen_on, freeze.app, signals: false ) end end extend Forwardable def_delegators :'self.class', :outbound_transfers def_delegators :request, :params def log opts[:common_logger] end def log_error(e) log.error( "Error raised during #{request.fullpath}: #{e.class}", e, loggable_params ) if e.is_a?(::Exception) Sentry.capture_exception(e) else Sentry.capture_message(e.to_s) end end def loggable_params params.dup.tap do |p| p.delete("to") p.delete("from") end end def customer_repo(**kwargs) kwargs[:set_user] = Sentry.method(:set_user) unless kwargs[:set_user] opts[:customer_repo] || CustomerRepo.new(**kwargs) end def call_attempt_repo opts[:call_attempt_repo] || CallAttemptRepo.new end TEL_CANDIDATES = { "Restricted" => "14", "anonymous" => "15", "Anonymous" => "16", "unavailable" => "17", "Unavailable" => "18" }.freeze def sanitize_tel_candidate(candidate) if candidate.length < 3 "13;phone-context=anonymous.phone-context.soprani.ca" elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1]) candidate else "#{TEL_CANDIDATES.fetch(candidate, '19')}" \ ";phone-context=anonymous.phone-context.soprani.ca" end end def from_jid Blather::JID.new( sanitize_tel_candidate(params["from"]), CONFIG[:component][:jid] ) end def inbound_calls_path(suffix, customer_id=nil) ["/inbound/calls/#{params['callId']}", suffix].compact.join("/") + (customer_id ? "?customer_id=#{customer_id}" : "") end def url(path) "#{request.base_url}#{path}" end def modify_call(call_id) body = Bandwidth::ApiModifyCallRequest.new yield body BANDWIDTH_VOICE.modify_call( CONFIG[:creds][:account], call_id, body: body ) rescue Bandwidth::ApiErrorResponseException # If call does not exist, don't need to hang up or send to voicemail # Other side must have hung up already raise $! unless $!.response_code.to_s == "404" end route do |r| r.on "inbound" do r.on "calls" do r.post "status" do if params["eventType"] == "disconnect" if (outbound_leg = outbound_transfers.delete(params["callId"])) modify_call(outbound_leg) do |call| call.state = "completed" end end customer_repo.find_by_tel(params["to"]).then do |customer| CDR.for_inbound(customer.customer_id, params).save end end "OK" end r.on :call_id do |call_id| r.post "transfer_complete" do outbound_leg = outbound_transfers.delete(call_id) if params["cause"] == "hangup" && params["tag"] == "connected" log.info "Normal hangup, now end #{call_id}", loggable_params modify_call(call_id) { |call| call.state = "completed" } elsif !outbound_leg log.debug "Inbound disconnected", loggable_params else log.debug "Go to voicemail", loggable_params modify_call(call_id) do |call| call.redirect_url = url inbound_calls_path(:voicemail) end end "" end r.on "voicemail" do r.post "audio" do duration = Time.parse(params["endTime"]) - Time.parse(params["startTime"]) next "OK<5" unless duration > 5 jmp_media_url = params["mediaUrl"].sub( /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/, "https://jmp.chat" ) customer_repo.find_by_tel(params["to"]).then do |customer| m = Blather::Stanza::Message.new m.chat_state = nil m.from = from_jid m.subject = "New Voicemail" m.body = jmp_media_url m << OOB.new(jmp_media_url, desc: "Voicemail Recording") customer.stanza_to(m) "OK" end end r.post "transcription" do duration = Time.parse(params["endTime"]) - Time.parse(params["startTime"]) next "OK<5" unless duration > 5 customer_repo.find_by_tel(params["to"]).then do |customer| m = Blather::Stanza::Message.new m.chat_state = nil m.from = from_jid m.subject = "Voicemail Transcription" m.body = BANDWIDTH_VOICE.get_recording_transcription( params["accountId"], params["callId"], params["recordingId"] ).data.transcripts[0].text customer.stanza_to(m) "OK" end end r.post do customer_repo(sgx_repo: Bwmsgsv2Repo.new) .find_by_tel(params["to"]) .then { |c| EMPromise.all([c, c.ogm(params["from"])]) }.then do |(customer, ogm)| render :voicemail, locals: { ogm: ogm, transcription_enabled: customer.transcription_enabled } end end end r.post do customer_repo( sgx_repo: Bwmsgsv2Repo.new ).find(params.fetch("customer_id")).then do |customer| call_attempt_repo.find_inbound( customer, params["from"], call_id: call_id, digits: params["digits"] ).then { |ca| render(*ca.to_render) } end end end r.post do customer_repo( sgx_repo: Bwmsgsv2Repo.new ).find_by_tel(params["to"]).then { |customer| EMPromise.all([ customer.customer_id, customer.fwd, call_attempt_repo.find_inbound( customer, params["from"], call_id: params["callId"] ) ]) }.then { |(customer_id, fwd, ca)| call = ca.create_call(fwd, CONFIG[:creds][:account]) { |cc| cc.from = params["from"] cc.application_id = params["applicationId"] cc.answer_url = url inbound_calls_path(nil, customer_id) cc.disconnect_url = url inbound_calls_path(:transfer_complete) } next EMPromise.reject(:voicemail) unless call outbound_transfers[params["callId"]] = call render :ring, locals: { duration: 300 } }.catch { |e| log_error(e) unless e == :voicemail render :redirect, locals: { to: inbound_calls_path(:voicemail) } } end end end r.on "outbound" do r.on "calls" do r.post "status" do log.info "#{params['eventType']} #{params['callId']}", loggable_params if params["eventType"] == "disconnect" call_attempt_repo.ending_call(c, params["callId"]) CDR.for_outbound(params).save.catch(&method(:log_error)) end "OK" end r.post do from = params["from"].sub(/^\+1/, "") customer_repo( sgx_repo: Bwmsgsv2Repo.new ).find_by_format(from).then do |c| call_attempt_repo.find_outbound( c, params["to"], call_id: params["callId"], digits: params["digits"] ).then do |ca| r.json { ca.to_json } call_attempt_repo.starting_call(c, params["callId"]) render(*ca.to_render) end end end end end r.on "ogm" do r.post "start" do render :record_ogm, locals: { customer_id: params["customer_id"] } end r.post do jmp_media_url = params["mediaUrl"].sub( /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/, "https://jmp.chat" ) ogm = OGMDownload.new(jmp_media_url) ogm.download.then do File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}") File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}") customer_repo.find(params["customer_id"]).then do |customer| customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3") end end end end r.public end end # rubocop:enable Metrics/ClassLength