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 a1704d5bf35cd7becf740ba345b52b470234a9da..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" @@ -14,9 +15,11 @@ gem "em_promise.rb", "~> 0.0.3" gem "eventmachine" gem "money-open-exchange-rates" gem "ougai" +gem "roda" gem "ruby-bandwidth-iris" gem "sentry-ruby", "<= 4.3.1" gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite" +gem "thin" gem "value_semantics", git: "https://github.com/singpolyma/value_semantics" group(:development) do 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/backend_sgx.rb b/lib/backend_sgx.rb index 8001e96674cfdb78cf786f74fa0ea5a3643ad49c..15b5591828ce45ac6c051df70f946cd5edc4625f 100644 --- a/lib/backend_sgx.rb +++ b/lib/backend_sgx.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class BackendSgx + VOICEMAIL_TRANSCRIPTION_DISABLED = 0 + def initialize(customer_id, jid=CONFIG[:sgx], creds=CONFIG[:creds]) @customer_id = customer_id @jid = jid @@ -33,6 +35,21 @@ class BackendSgx end end + def ogm_url + REDIS.get("catapult_ogm_url-#{from_jid}") + end + + def catapult_flag(flagbit) + REDIS.getbit( + "catapult_setting_flags-#{from_jid}", + flagbit + ).then { |x| x != 1 } + end + + def fwd_timeout + REDIS.get("catapult_fwd_timeout-#{from_jid}") + end + def set_fwd_timeout(timeout) REDIS.set("catapult_fwd_timeout-#{from_jid}", timeout) end diff --git a/lib/cdr.rb b/lib/cdr.rb new file mode 100644 index 0000000000000000000000000000000000000000..de9d1f43cc58fd37a5368349181a7b2c092d0c2a --- /dev/null +++ b/lib/cdr.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" + +class CDR + module Disposition + def self.===(other) + ["NO ANSWER", "ANSWERED", "BUSY", "FAILED"].include?(other) + end + + def self.for(cause) + case cause + when "timeout", "rejected", "cancel" + "NO ANSWER" + when "hangup" + "ANSWERED" + when "busy" + "BUSY" + else + "FAILED" + end + end + end + + value_semantics do + cdr_id String + customer_id String + start Time + billsec Integer + disposition Disposition + tel(/\A\+\d+\Z/) + direction Either(:inbound, :outbound) + end + + def self.for(event, **kwargs) + start = Time.parse(event["startTime"]) + + new({ + cdr_id: "sgx-jmp/#{event['callId']}", + start: start, + billsec: (Time.parse(event["endTime"]) - start).ceil, + disposition: Disposition.for(event["cause"]) + }.merge(kwargs)) + end + + def self.for_inbound(customer_id, event) + self.for( + event, + customer_id: customer_id, + tel: event["from"], + direction: :inbound + ) + end + + def self.for_outbound(event) + self.for( + event, + customer_id: event["from"].sub(/^\+/, ""), + tel: event["to"], + direction: :outbound + ) + end + + def save + columns, values = to_h.to_a.transpose + DB.query_defer(<<~SQL, values) + INSERT INTO cdr (#{columns.join(',')}) + VALUES ($1, $2, $3, $4, $5, $6, $7) + SQL + end +end diff --git a/lib/customer.rb b/lib/customer.rb index 160013041ccc17f883b8d5e38836de75ce53a033..a2916229e12f968ac7401daecb39d20fd1429138 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -5,6 +5,7 @@ require "forwardable" require_relative "./api" require_relative "./blather_ext" require_relative "./customer_info" +require_relative "./customer_ogm" require_relative "./customer_plan" require_relative "./customer_usage" require_relative "./backend_sgx" @@ -20,7 +21,8 @@ class Customer attr_reader :customer_id, :balance, :jid def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan, :currency, :merchant_account, :plan_name, :auto_top_up_amount - def_delegators :@sgx, :register!, :registered?, :set_fwd_timeout + def_delegators :@sgx, :register!, :registered?, + :fwd_timeout, :set_fwd_timeout, :catapult_flag def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage def initialize( @@ -67,13 +69,25 @@ class Customer stanza = stanza.dup stanza.to = jid.with(resource: stanza.to&.resource) stanza.from = stanza.from.with(domain: CONFIG[:component][:jid]) - BLATHER << stanza + block_given? ? yield(stanza) : (BLATHER << stanza) end def stanza_from(stanza) BLATHER << @sgx.stanza(stanza) end + def fetch_vcard_temp(from_tel=nil) + iq = Blather::Stanza::Iq::Vcard.new(:get) + iq.from = Blather::JID.new(from_tel, CONFIG[:component][:jid]) + stanza_to(iq, &IQ_MANAGER.method(:write)).then(&:vcard) + end + + def ogm(from_tel=nil) + @sgx.ogm_url.then do |url| + CustomerOGM.for(url, -> { fetch_vcard_temp(from_tel) }) + end + end + def sip_account SipAccount.find(customer_id) end diff --git a/lib/customer_ogm.rb b/lib/customer_ogm.rb new file mode 100644 index 0000000000000000000000000000000000000000..d52164181ec3244b32edf5e513b88e250baa3859 --- /dev/null +++ b/lib/customer_ogm.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module CustomerOGM + def self.for(url, fetch_vcard_temp) + return Media.new(url) if url + TTS.for(fetch_vcard_temp) + end + + class Media + def initialize(url) + @url = url + end + + def to_render + [:voicemail_ogm_media, locals: { url: @url }] + end + end + + class TTS + def self.for(fetch_vcard_temp) + fetch_vcard_temp.call.then { |vcard| + new(vcard) + }.catch { new(Blather::Stanza::Iq::Vcard::Vcard.new) } + end + + def initialize(vcard) + @vcard = vcard + end + + def [](k) + value = @vcard[k] + return if value.to_s.empty? + value + end + + def fn + self["FN"] || self["NICKNAME"] || "a user of JMP.chat" + end + + def to_render + [:voicemail_ogm_tts, locals: { fn: fn }] + end + end +end diff --git a/lib/rack_fiber.rb b/lib/rack_fiber.rb new file mode 100644 index 0000000000000000000000000000000000000000..c30e06e18dd3dc5f79c5c03df75035a49e5807b9 --- /dev/null +++ b/lib/rack_fiber.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "fiber" + +module Rack + class Fiber + def initialize(app) + @app = app + end + + def call(env) + async_callback = env.delete("async.callback") + EM.next_tick { run_fiber(env, async_callback) } + throw :async + end + + protected + + def run_fiber(env, async_callback) + ::Fiber.new { + begin + async_callback.call(@app.call(env)) + rescue ::Exception # rubocop:disable Lint/RescueException + async_callback.call([500, {}, [$!.to_s]]) + end + }.resume + end + end +end 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/lib/roda_em_promise.rb b/lib/roda_em_promise.rb new file mode 100644 index 0000000000000000000000000000000000000000..d26ea66cdaf3a6698a2bc75438d8d603975e40d7 --- /dev/null +++ b/lib/roda_em_promise.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "em_promise" + +module RodaEMPromise + module RequestMethods + def block_result(result) + super(EMPromise.resolve(result).sync) + end + end +end diff --git a/public/beep.mp3 b/public/beep.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..0d1995ae736484d927480a856a447eafd1a2f724 Binary files /dev/null and b/public/beep.mp3 differ diff --git a/sgx_jmp.rb b/sgx_jmp.rb index 90efd568108a24fe3fa789a723be21f84769b46d..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 @@ -80,6 +86,7 @@ require_relative "lib/transaction" require_relative "lib/tel_selections" require_relative "lib/session_manager" require_relative "lib/statsd" +require_relative "web" ELECTRUM = Electrum.new(**CONFIG[:electrum]) EM::Hiredis::Client.load_scripts_from("./redis_lua") @@ -189,6 +196,8 @@ when_ready do ping.from = CONFIG[:component][:jid] self << ping end + + Web.run(LOG.child, CustomerRepo.new, *WEB_LISTEN) end # workqueue_count MUST be 0 or else Blather uses threads! diff --git a/test/test_cdr.rb b/test/test_cdr.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d6e8581b9907cc3175bf5204fd762f78aa30ed7 --- /dev/null +++ b/test/test_cdr.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "test_helper" +require "cdr" + +class CDRTest < Minitest::Test + def test_for_inbound + cdr = CDR.for_inbound( + "test", + "from" => "+15551234567", + "startTime" => "2020-01-01T00:00:00Z", + "endTime" => "2020-01-01T01:00:00Z", + "callId" => "a_call", + "cause" => "hangup" + ) + assert_equal cdr.cdr_id, "sgx-jmp/a_call" + assert_equal cdr.customer_id, "test" + assert_equal cdr.start, Time.parse("2020-01-01T00:00:00Z") + assert_equal cdr.billsec, 60 * 60 + assert_equal cdr.disposition, "ANSWERED" + assert_equal cdr.tel, "+15551234567" + assert_equal cdr.direction, :inbound + end + + def test_for_outbound + cdr = CDR.for_outbound( + "to" => "+15551234567", + "from" => "+test", + "startTime" => "2020-01-01T00:00:00Z", + "endTime" => "2020-01-01T01:00:00Z", + "callId" => "a_call", + "cause" => "hangup" + ) + assert_equal cdr.cdr_id, "sgx-jmp/a_call" + assert_equal cdr.customer_id, "test" + assert_equal cdr.start, Time.parse("2020-01-01T00:00:00Z") + assert_equal cdr.billsec, 60 * 60 + assert_equal cdr.disposition, "ANSWERED" + assert_equal cdr.tel, "+15551234567" + assert_equal cdr.direction, :outbound + end +end diff --git a/test/test_customer.rb b/test/test_customer.rb index defcb2d9b1fc15c7432bc28078a40f885092f156..212bb24688e8b81b721822524e66dfb843a112ed 100644 --- a/test/test_customer.rb +++ b/test/test_customer.rb @@ -8,6 +8,7 @@ Customer::BRAINTREE = Minitest::Mock.new Customer::ELECTRUM = Minitest::Mock.new Customer::REDIS = Minitest::Mock.new Customer::DB = Minitest::Mock.new +Customer::IQ_MANAGER = Minitest::Mock.new CustomerPlan::DB = Minitest::Mock.new CustomerUsage::REDIS = Minitest::Mock.new CustomerUsage::DB = Minitest::Mock.new @@ -109,6 +110,18 @@ class CustomerTest < Minitest::Test Customer::BLATHER.verify end + def test_fetch_vcard_temp + result = Blather::Stanza::Iq::Vcard.new(:result) + result.vcard["FN"] = "name" + Customer::IQ_MANAGER.expect( + :method, + ->(*) { EMPromise.resolve(result) }, + [:write] + ) + assert_equal "name", customer.fetch_vcard_temp("+15551234567").sync["FN"] + end + em :test_fetch_vcard_temp + def test_customer_usage_report report_for = (Date.today..(Date.today - 1)) report_for.first.downto(report_for.last).each.with_index do |day, idx| diff --git a/test/test_customer_ogm.rb b/test/test_customer_ogm.rb new file mode 100644 index 0000000000000000000000000000000000000000..d272cddb36a07742665076c9ad800dd4ec58c1f5 --- /dev/null +++ b/test/test_customer_ogm.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "test_helper" +require "customer_ogm" + +class CustomerOGMTest < Minitest::Test + def test_for_url + assert_kind_of( + CustomerOGM::Media, + CustomerOGM.for("https://example.com/test.mp3", -> {}) + ) + end + + def test_for_no_url + assert_kind_of( + CustomerOGM::TTS, + CustomerOGM.for(nil, -> { EMPromise.resolve(nil) }).sync + ) + end + em :test_for_no_url + + class TTSTest < Minitest::Test + def test_to_render_empty_vcard + vcard = Blather::Stanza::Iq::Vcard::Vcard.new + assert_equal( + [:voicemail_ogm_tts, locals: { fn: "a user of JMP.chat" }], + CustomerOGM::TTS.new(vcard).to_render + ) + end + + def test_to_render_fn + vcard = Blather::Stanza::Iq::Vcard::Vcard.new + vcard["FN"] = "name" + assert_equal( + [:voicemail_ogm_tts, locals: { fn: "name" }], + CustomerOGM::TTS.new(vcard).to_render + ) + end + + def test_to_render_nickname + vcard = Blather::Stanza::Iq::Vcard::Vcard.new + vcard["NICKNAME"] = "name" + assert_equal( + [:voicemail_ogm_tts, locals: { fn: "name" }], + CustomerOGM::TTS.new(vcard).to_render + ) + end + end +end 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/forward.slim b/views/forward.slim new file mode 100644 index 0000000000000000000000000000000000000000..cb264e2937a4d3b552e9173329b797e20de56ce4 --- /dev/null +++ b/views/forward.slim @@ -0,0 +1,3 @@ +doctype xml +Response + Forward from=from to=to / 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..85118ed8bb2f260263d1babfda18ede2eb381a33 --- /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=transcription_enabled.to_s + 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 new file mode 100644 index 0000000000000000000000000000000000000000..84911dff2770b71a5f3bb232051ebcc9a5a731a3 --- /dev/null +++ b/web.rb @@ -0,0 +1,369 @@ +# 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 + attr_reader :true_inbound_call, :outbound_transfers + + 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 + 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 + + 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 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" + 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| + EMPromise.all([ + customer.ogm(params["from"]), + customer.catapult_flag( + BackendSgx::VOICEMAIL_TRANSCRIPTION_DISABLED + ) + ]) + }.then do |(ogm, transcription_disabled)| + render :voicemail, locals: { + ogm: ogm, + transcription_enabled: !transcription_disabled + } + 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 + + r.post do + customer_id = params["from"].sub(/^\+/, "") + customer_repo.find(customer_id).then(:registered?).then do |reg| + render :forward, locals: { + from: reg.phone, + to: params["to"] + } + end + end + end + end + + r.public + end +end +# rubocop:enable Metrics/ClassLength