# frozen_string_literal: true require "digest" require "forwardable" require "multibases" require "multihashes" require "roda" require "thin" require "sentry-ruby" require_relative "lib/cdr" 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 # Must go first! use Sentry::Rack::CaptureExceptions plugin :json_parser 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.full_path}: #{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 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 elsif candidate == "Restricted" "#{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) ["/inbound/calls/#{params['callId']}", suffix].compact.join("/") 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 ) 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 CustomerRepo.new.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" log.debug "Normal hangup", loggable_params 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" ) CustomerRepo.new.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 CustomerRepo.new.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 CustomerRepo .new(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 render :bridge, locals: { call_id: call_id } end end r.post do CustomerRepo.new( sgx_repo: Bwmsgsv2Repo.new ).find_by_tel(params["to"]).then(&:fwd).then do |fwd| call = fwd.create_call(CONFIG[:creds][:account]) { |cc| cc.from = params["from"] cc.application_id = params["applicationId"] cc.answer_url = url inbound_calls_path(nil) cc.disconnect_url = url inbound_calls_path(:transfer_complete) } if call outbound_transfers[params["callId"]] = call render :ring, locals: { duration: 300 } else render :redirect, locals: { to: inbound_calls_path(:voicemail) } end end 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" CDR.for_outbound(params).save.catch(&method(:log_error)) end "OK" end r.post do customer_id = params["from"].sub(/^\+1/, "") CustomerRepo.new( sgx_repo: Bwmsgsv2Repo.new ).find(customer_id).then do |c| render :forward, locals: { from: c.registered?.phone, to: params["to"] } 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}") CustomerRepo.new.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