From 7d506e05e7d5e0a9e1c20c65bf2da4743caccd15 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 13:35:24 -0500 Subject: [PATCH] Port in inbound calls + voicemail The craziest part of this is the workaround for a serious bug in Bandwidth's HTTP voice API (which they may yet fix, still negotiating with them about that). When a call comes in, every 10 seconds that it is not "answered" the inbound call gets cancelled by their upstream peer and then get retried. The caller sees only one oubound call for this, so it doesn't look odd to them, but to us it looks like they keep hanging up and trying again every 10 seconds. So what we do for now is we wait 2 seconds after they disconnect before we decide they're really gone. If they call back in those 2 seconds we just connect the eventual bridge or voicemail to this new call and everything works out. Ew. --- .rubocop.yml | 3 + Gemfile | 1 + config-schema.dhall | 1 + config.dhall.sample | 3 + lib/roda_capture.rb | 37 ++++ public/beep.mp3 | Bin 0 -> 8493 bytes sgx_jmp.rb | 8 +- views/bridge.slim | 3 + views/pause.slim | 3 + views/redirect.slim | 3 + views/voicemail.slim | 10 + views/voicemail_ogm_media.slim | 1 + views/voicemail_ogm_tts.slim | 4 + web.rb | 330 ++++++++++++++++++++++++++++++--- 14 files changed, 384 insertions(+), 23 deletions(-) create mode 100644 lib/roda_capture.rb create mode 100644 public/beep.mp3 create mode 100644 views/bridge.slim create mode 100644 views/pause.slim create mode 100644 views/redirect.slim create mode 100644 views/voicemail.slim create mode 100644 views/voicemail_ogm_media.slim create mode 100644 views/voicemail_ogm_tts.slim diff --git a/.rubocop.yml b/.rubocop.yml index 17be246f2e8e90d956918240381dee95f886d51f..72c850da046a336cc447447698f91c7b567431cf 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -15,6 +15,9 @@ Metrics/MethodLength: - test/* Metrics/BlockLength: + ExcludedMethods: + - route + - "on" Exclude: - test/* diff --git a/Gemfile b/Gemfile index aa2984b2d9bee21fe73f7e11471a56487d0ec2ac..e5e933f65ab61580fd2274ee8890a24e41a95ced 100644 --- a/Gemfile +++ b/Gemfile @@ -3,6 +3,7 @@ source "https://rubygems.org" gem "amazing_print" +gem "bandwidth-sdk" gem "blather", git: "https://github.com/singpolyma/blather.git", branch: "ergonomics" gem "braintree" gem "dhall" diff --git a/config-schema.dhall b/config-schema.dhall index d4617a5362b5b0cabe659281f8e95885c771c745..154da2a853daf41b80177f4e85a1f39968e03cf2 100644 --- a/config-schema.dhall +++ b/config-schema.dhall @@ -43,6 +43,7 @@ , sgx : Text , sip_host : Text , upstream_domain : Text +, web : < Inet : { interface : Text, port : Natural } | Unix : Text > , web_register : { from : Text, to : Text } , xep0157 : List { label : Text, value : Text, var : Text } } diff --git a/config.dhall.sample b/config.dhall.sample index ae4bf1ab98cd5bd3bddc71b2d90358c26902faad..465e0d6751fda427a1b2540af784c37beb88dad9 100644 --- a/config.dhall.sample +++ b/config.dhall.sample @@ -1,3 +1,5 @@ +let ListenOn = < Inet: { interface: Text, port: Natural } | Unix: Text > +in { component = { jid = "component.localhost", @@ -8,6 +10,7 @@ port = 5347 }, sgx = "component2.localhost", + web = ListenOn.Inet { interface = "::1", port = env:PORT ? 8080 }, creds = { account = "00000", username = "dashboard user", diff --git a/lib/roda_capture.rb b/lib/roda_capture.rb new file mode 100644 index 0000000000000000000000000000000000000000..9ed2752ebf3873ff10d638b500059e5a0908a1e0 --- /dev/null +++ b/lib/roda_capture.rb @@ -0,0 +1,37 @@ +# frozen-string-literal: true + +# Build from official params_capturing plugin +class RodaCapture + module RequestMethods + def captures_hash + @captures_hash ||= {} + end + + private + + # Add the symbol to the list of capture names if capturing + def _match_symbol(sym) + @_sym_captures << sym if @_sym_captures + + super + end + + # If all arguments are strings or symbols, turn on param capturing during + # the matching, but turn it back off before yielding to the block. Add + # any captures to the params based on the param capture names added by + # the matchers. + def if_match(args) + @_sym_captures = [] if args.all? { |x| x.is_a?(Symbol) } + + super do |*a| + if @_sym_captures + @_sym_captures.zip(a).each do |k, v| + captures_hash[k] = v + end + @_sym_captures = nil + end + yield(*a) + end + end + end +end diff --git a/public/beep.mp3 b/public/beep.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0d1995ae736484d927480a856a447eafd1a2f724 GIT binary patch literal 8493 zcmeZtF=k-^0p*b3U{@f`&%nU!lUSB!YOZHytY>Io0G5Ri|9^)ZK;lA}o_T5cKo(FR zgEj+$A_D_Ax1gZ7xU8(Kl9Gmoo}QkGiItUugM+K9w|8J*V0d_JY*JEEMn+yeWS}F>O+g$OP+>j*VJb2xh=n|Y2}5b1`;Ra%a5k8Sl(sZ5MvAQ2 zZYsdQ=y7M_+znsv?Y>?e-+jhgam#b2o^z9-pc&EE0Ig@vinVIdO-^MfCD{HK`M z4K3^s@;ul3_ilcjg6kd6ZZ1`O;s+w1$~Y!^^>ChTOp)GkI96X9~^bpB24yxg_Vq{Sw>^=`%zQOq!w6pgx0JK}|MnmK|Fz z*Z%)-5Tmq4LSlMLRVABb@^854|F5fBLYtX|KJ@!59M-9?hfDn1;TmCJsL%1`6OYmH z?{MM&ukk4dxnH&U&<9y5hM-3<_mgM_7Ue#UzOKf4mX>;^t4Id;oQr7{(EZ8i?*IRq z68lMZFq#IE-M?^%yZ_**`_YmBMO>28&uI8l6yE5XN5g-Jm7k;OA3X_E#2-!nqv@ZL z1T>m{M$^w|`XRP=2JF9#)?bt)7Yx0l<&qv>Zf{a_>t5(Gxm55Dw+BXl-!e)Vzv6lb8ppv1t>(Qwq@K*I)u zE@lpc4ULx!)*qQ}c<;no!?22*hT^9*3~L%144NJD4eSqE8n_(DHDGMu$!xb@U~qF! zQskE}-!wH14)YlNvE23L)2^DY@%D!%O`7mPVMC2w%#Q|v0!8*)u^)~Y#slYE|Ns95 zn!m&7KYqQ+z@X5~#mdCc;KIP8!`)-R$-~Ee#!Tqbmg8rn4)~O4`tV7-I^`p$!;`|& zvrNaQMYLd7&9#Dci5s%!_A?9XEX?4!A|MnU_ka1JnM)$mBZQjkf9=oX;^gRAV!Y^F zME2YN|KI&x5vy7rcq%(k{OjMT_5c4r^!R)E@_vztE3(pzCD-}_9rsQ3%eSXfrm22f zwPnlJl`A)b9K3wH&XSG#Aad!_Q?q8vV0C}twRn))KilM47!p4?u&7$e{-4gjY9J@| zaOEG6`#&`~LFP{xAN4Ay?VrGaOYtBS2f6=$gZNWJ_sBa9z`1gh<^~3qLFi6gP67aR ClknyM literal 0 HcmV?d00001 diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 24810406d109a84ffa2c434b5f8626507b693a9f..fc40b3871747e12b9b0f48d58f91115710880695 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -52,6 +52,12 @@ CONFIG = "(#{ARGV[0]}) : #{__dir__}/config-schema.dhall", transform_keys: ->(k) { k&.to_sym } ) +WEB_LISTEN = + if CONFIG[:web].is_a?(Hash) + [CONFIG[:web][:interface], CONFIG[:web][:port]] + else + [CONFIG[:web]] + end singleton_class.class_eval do include Blather::DSL @@ -191,7 +197,7 @@ when_ready do self << ping end - Web.run(LOG.child, CustomerRepo.new) + Web.run(LOG.child, CustomerRepo.new, *WEB_LISTEN) end # workqueue_count MUST be 0 or else Blather uses threads! diff --git a/views/bridge.slim b/views/bridge.slim new file mode 100644 index 0000000000000000000000000000000000000000..a97957cebd1a5d2c2bd916536578cb2088b51cb5 --- /dev/null +++ b/views/bridge.slim @@ -0,0 +1,3 @@ +doctype xml +Response + Bridge= call_id diff --git a/views/pause.slim b/views/pause.slim new file mode 100644 index 0000000000000000000000000000000000000000..e892a580a0f6d700ea5174463ba90bf87e1ba5ee --- /dev/null +++ b/views/pause.slim @@ -0,0 +1,3 @@ +doctype xml +Response + Pause duration=duration diff --git a/views/redirect.slim b/views/redirect.slim new file mode 100644 index 0000000000000000000000000000000000000000..8d3310a6d8b46e197ce9a6125a705a0c3670584c --- /dev/null +++ b/views/redirect.slim @@ -0,0 +1,3 @@ +doctype xml +Response + Redirect redirectUrl=to / diff --git a/views/voicemail.slim b/views/voicemail.slim new file mode 100644 index 0000000000000000000000000000000000000000..7d5310118cd7c6ebd86702d4e9ee140cd10eefee --- /dev/null +++ b/views/voicemail.slim @@ -0,0 +1,10 @@ +doctype xml +Response + Pause duration=2 + == render(*ogm.to_render) + PlayAudio= "/beep.mp3" + Record{ + transcribe="true" + recordingAvailableUrl="/inbound/calls/#{pseudo_call_id}/voicemail/audio" + transcriptionAvailableUrl="/inbound/calls/#{pseudo_call_id}/voicemail/transcription" + fileFormat="mp3"} / diff --git a/views/voicemail_ogm_media.slim b/views/voicemail_ogm_media.slim new file mode 100644 index 0000000000000000000000000000000000000000..87fc82d905ead976e8b68f97d72786722f9d3865 --- /dev/null +++ b/views/voicemail_ogm_media.slim @@ -0,0 +1 @@ +PlayAudio= url diff --git a/views/voicemail_ogm_tts.slim b/views/voicemail_ogm_tts.slim new file mode 100644 index 0000000000000000000000000000000000000000..052b2b2f6004480679b0de265c7cbd11e0142950 --- /dev/null +++ b/views/voicemail_ogm_tts.slim @@ -0,0 +1,4 @@ +SpeakSentence + ' You have reached the voicemail of + = fn + | . Please send a text message, or leave a message after the tone. diff --git a/web.rb b/web.rb index 1e97e46afd1f9fad9b53e54cce4c01722338e43b..f55b987b6b9b6b0f4514850ad4b9e5239a8af2d7 100644 --- a/web.rb +++ b/web.rb @@ -1,61 +1,344 @@ # frozen_string_literal: true +require "digest" +require "forwardable" require "roda" require "thin" require "sentry-ruby" +require "bandwidth" + +Faraday.default_adapter = :em_synchrony require_relative "lib/cdr" +require_relative "lib/roda_capture" require_relative "lib/roda_em_promise" require_relative "lib/rack_fiber" +BANDWIDTH_VOICE = Bandwidth::Client.new( + voice_basic_auth_user_name: CONFIG[:creds][:username], + voice_basic_auth_password: CONFIG[:creds][:password] +).voice_client.client + +module CustomerFwd + def self.from_redis(redis, customer, tel) + EMPromise.all([ + redis.get("catapult_fwd-#{tel}"), + customer.fwd_timeout + ]).then do |(fwd, stimeout)| + timeout = Timeout.new(stimeout) + next if !fwd || timeout.zero? + self.for(fwd, timeout) + end + end + + def self.for(uri, timeout) + case uri + when /^tel:/ + Tel.new(uri, timeout) + when /^sip:/ + SIP.new(uri, timeout) + when /^xmpp:/ + XMPP.new(uri, timeout) + else + raise "Unknown forward URI: #{uri}" + end + end + + class Timeout + def initialize(s) + @timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i + end + + def zero? + @timeout.zero? + end + + def to_i + @timeout + end + end + + class Tel + attr_reader :timeout + + def initialize(uri, timeout) + @tel = uri.sub(/^tel:/, "") + @timeout = timeout + end + + def to + @tel + end + end + + class SIP + attr_reader :timeout + + def initialize(uri, timeout) + @uri = uri + @timeout = timeout + end + + def to + @uri + end + end + + class XMPP + attr_reader :timeout + + def initialize(uri, timeout) + @jid = uri.sub(/^xmpp:/, "") + @timeout = timeout + end + + def to + "sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com" + end + 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 - end + attr_reader :true_inbound_call, :outbound_transfers - def customer_repo - Web.customer_repo + def run(log, customer_repo, *listen_on) + plugin :common_logger, log, method: :info + @customer_repo = customer_repo + @true_inbound_call = {} + @outbound_transfers = {} + Thin::Logging.logger = log + Thin::Server.start( + *listen_on, + freeze.app, + signals: false + ) + end end + extend Forwardable + def_delegators :'self.class', :customer_repo, :true_inbound_call, + :outbound_transfers + def_delegators :request, :params + def log - Web.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 + + def pseudo_call_id + request.captures_hash[:pseudo_call_id] || + Digest::SHA256.hexdigest("#{params['from']},#{params['to']}") end - def params - request.params + 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/#{pseudo_call_id}", suffix].compact.join("/") + end + + def url(path) + "#{request.base_url}#{path}" end - def self.run(log, customer_repo) - plugin :common_logger, log, method: :info - @log = log - @customer_repo = customer_repo - Thin::Logging.logger = log - Thin::Server.start( - "::1", - ENV.fetch("PORT", 8080), - freeze.app, - signals: false + 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 "outbound" do + r.on "inbound" do r.on "calls" do r.post "status" do - loggable = params.dup.tap { |p| p.delete("to") } - log.info "#{params['eventType']} #{params['callId']}", loggable if params["eventType"] == "disconnect" - CDR.for_disconnect(params).save.catch do |e| - log.error("Error raised during /outbound/calls/status", e, loggable) - Sentry.capture_exception(e) + p_call_id = pseudo_call_id + call_id = params["callId"] + EM.promise_timer(2).then { + next unless true_inbound_call[p_call_id] == call_id + true_inbound_call.delete(p_call_id) + + if (outbound_leg = outbound_transfers.delete(p_call_id)) + 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 + }.catch(&method(:log_error)) + end + "OK" + end + + r.on :pseudo_call_id do |pseudo_call_id| + r.post "transfer_complete" do + outbound_leg = outbound_transfers.delete(pseudo_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 + true_call_id = true_inbound_call[pseudo_call_id] + modify_call(true_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 + 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 + .find_by_tel(params["to"]) + .then { |customer| customer.ogm(params["from"]) } + .then do |ogm| + render :voicemail, locals: { ogm: ogm } + end end end + + r.post do + true_call_id = true_inbound_call[pseudo_call_id] + render :bridge, locals: { call_id: true_call_id } + end + end + + r.post do + if true_inbound_call[pseudo_call_id] + true_inbound_call[pseudo_call_id] = params["callId"] + return render :pause, locals: { duration: 300 } + end + + customer_repo.find_by_tel(params["to"]).then do |customer| + CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd| + if fwd + body = Bandwidth::ApiCreateCallRequest.new.tap do |cc| + cc.to = fwd.to + cc.from = params["from"] + cc.application_id = params["applicationId"] + cc.call_timeout = fwd.timeout.to_i + cc.answer_url = url inbound_calls_path(nil) + cc.disconnect_url = url inbound_calls_path(:transfer_complete) + end + true_inbound_call[pseudo_call_id] = params["callId"] + outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call( + CONFIG[:creds][:account], body: body + ).data.call_id + render :pause, locals: { duration: 300 } + else + render :redirect, locals: { to: inbound_calls_path(:voicemail) } + end + 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 @@ -70,5 +353,8 @@ class Web < Roda end end end + + r.public end end +# rubocop:enable Metrics/ClassLength