From 3260c8a99a392510d2c52f54ac76eeb854a441f8 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 8 Sep 2021 13:09:45 -0500 Subject: [PATCH 1/8] Outbound calls from v2 SIP endpoint work and save a CDR --- Gemfile | 2 ++ lib/cdr.rb | 52 +++++++++++++++++++++++++++++ lib/rack_fiber.rb | 29 +++++++++++++++++ lib/roda_em_promise.rb | 11 +++++++ sgx_jmp.rb | 3 ++ views/forward.slim | 3 ++ web.rb | 74 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 174 insertions(+) create mode 100644 lib/cdr.rb create mode 100644 lib/rack_fiber.rb create mode 100644 lib/roda_em_promise.rb create mode 100644 views/forward.slim create mode 100644 web.rb diff --git a/Gemfile b/Gemfile index a1704d5bf35cd7becf740ba345b52b470234a9da..aa2984b2d9bee21fe73f7e11471a56487d0ec2ac 100644 --- a/Gemfile +++ b/Gemfile @@ -14,9 +14,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/lib/cdr.rb b/lib/cdr.rb new file mode 100644 index 0000000000000000000000000000000000000000..efac8ab0e76a3b59fc07eaacc52352d10873cee9 --- /dev/null +++ b/lib/cdr.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "value_semantics/monkey_patched" + +class CDR + value_semantics do + cdr_id String + customer_id String + start Time + billsec Integer + disposition Either("NO ANSWER", "ANSWERED", "BUSY", "FAILED") + tel(/\A\+\d+\Z/) + direction Either(:inbound, :outbound) + end + + def self.for_disconnect(event) + start = Time.parse(event["startTime"]) + + new( + cdr_id: "sgx-jmp/#{event['callId']}", + customer_id: event["from"].sub(/^\+/, ""), + start: start, + billsec: (Time.parse(event["endTime"]) - start).ceil, + disposition: Disposition.for(event["cause"]), + 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 + + module Disposition + def self.for(cause) + case cause + when "timeout", "rejected", "cancel" + "NO ANSWER" + when "hangup" + "ANSWERED" + when "busy" + "BUSY" + else + "FAILED" + end + 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_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/sgx_jmp.rb b/sgx_jmp.rb index 90efd568108a24fe3fa789a723be21f84769b46d..24810406d109a84ffa2c434b5f8626507b693a9f 100644 --- a/sgx_jmp.rb +++ b/sgx_jmp.rb @@ -80,6 +80,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 +190,8 @@ when_ready do ping.from = CONFIG[:component][:jid] self << ping end + + Web.run(LOG.child, CustomerRepo.new) end # workqueue_count MUST be 0 or else Blather uses threads! 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/web.rb b/web.rb new file mode 100644 index 0000000000000000000000000000000000000000..1e97e46afd1f9fad9b53e54cce4c01722338e43b --- /dev/null +++ b/web.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "roda" +require "thin" +require "sentry-ruby" + +require_relative "lib/cdr" +require_relative "lib/roda_em_promise" +require_relative "lib/rack_fiber" + +class Web < Roda + use Rack::Fiber # Must go first! + use Sentry::Rack::CaptureExceptions + plugin :json_parser + plugin :render, engine: "slim" + plugin RodaEMPromise # Must go last! + + class << self + attr_reader :customer_repo, :log + end + + def customer_repo + Web.customer_repo + end + + def log + Web.log + end + + def params + request.params + 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 + ) + end + + route do |r| + r.on "outbound" 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) + end + 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 + end +end From 07c9c58dcf82ddea69eb16bea325b8945435d403 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 13:11:18 -0500 Subject: [PATCH 2/8] Allow constructing CDR for an inbound or outbound event --- lib/cdr.rb | 23 +++++++++++++++++++---- test/test_cdr.rb | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 test/test_cdr.rb diff --git a/lib/cdr.rb b/lib/cdr.rb index efac8ab0e76a3b59fc07eaacc52352d10873cee9..b8077378605cdd995d06fff3ada51ceebfd14e77 100644 --- a/lib/cdr.rb +++ b/lib/cdr.rb @@ -13,15 +13,30 @@ class CDR direction Either(:inbound, :outbound) end - def self.for_disconnect(event) + def self.for(event, **kwargs) start = Time.parse(event["startTime"]) - new( + new({ cdr_id: "sgx-jmp/#{event['callId']}", - customer_id: event["from"].sub(/^\+/, ""), start: start, billsec: (Time.parse(event["endTime"]) - start).ceil, - disposition: Disposition.for(event["cause"]), + 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 ) 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 From a268e15e906522ab44bfd5a32da8bb99140d11f4 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 14:15:21 -0500 Subject: [PATCH 3/8] Make Disposition more real --- lib/cdr.rb | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/lib/cdr.rb b/lib/cdr.rb index b8077378605cdd995d06fff3ada51ceebfd14e77..de9d1f43cc58fd37a5368349181a7b2c092d0c2a 100644 --- a/lib/cdr.rb +++ b/lib/cdr.rb @@ -3,12 +3,31 @@ 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 Either("NO ANSWER", "ANSWERED", "BUSY", "FAILED") + disposition Disposition tel(/\A\+\d+\Z/) direction Either(:inbound, :outbound) end @@ -49,19 +68,4 @@ class CDR VALUES ($1, $2, $3, $4, $5, $6, $7) SQL end - - module Disposition - def self.for(cause) - case cause - when "timeout", "rejected", "cancel" - "NO ANSWER" - when "hangup" - "ANSWERED" - when "busy" - "BUSY" - else - "FAILED" - end - end - end end From c7ebad17fa39584f92a6d317dc2f465990fe088d Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 13:18:26 -0500 Subject: [PATCH 4/8] Helper to fetch customer's vcard-temp --- lib/customer.rb | 8 +++++++- test/test_customer.rb | 13 +++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/customer.rb b/lib/customer.rb index 160013041ccc17f883b8d5e38836de75ce53a033..4cd6619deb2fe947319c7ceda6c26fb4a212dd6e 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -67,13 +67,19 @@ 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 sip_account SipAccount.find(customer_id) 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| From b4b54eb17c49f380d5b2285fc1576b892fe62f8a Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 13:29:31 -0500 Subject: [PATCH 5/8] Get OGM for a customer --- lib/backend_sgx.rb | 4 ++++ lib/customer.rb | 7 ++++++ lib/customer_ogm.rb | 44 +++++++++++++++++++++++++++++++++++ test/test_customer_ogm.rb | 49 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+) create mode 100644 lib/customer_ogm.rb create mode 100644 test/test_customer_ogm.rb diff --git a/lib/backend_sgx.rb b/lib/backend_sgx.rb index 8001e96674cfdb78cf786f74fa0ea5a3643ad49c..2bcb112479c0b8d72bf9245693219d24aa828425 100644 --- a/lib/backend_sgx.rb +++ b/lib/backend_sgx.rb @@ -33,6 +33,10 @@ class BackendSgx end end + def ogm_url + REDIS.get("catapult_ogm_url-#{from_jid}") + end + def set_fwd_timeout(timeout) REDIS.set("catapult_fwd_timeout-#{from_jid}", timeout) end diff --git a/lib/customer.rb b/lib/customer.rb index 4cd6619deb2fe947319c7ceda6c26fb4a212dd6e..b66858371fc897d0fbcd1d6d660a97fa12fd4562 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" @@ -80,6 +81,12 @@ class Customer 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/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 From e5b66f4d5b0477ed2746aaf44e870e43908269b1 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 13:31:14 -0500 Subject: [PATCH 6/8] Allow fetching fwd timeout as well --- lib/backend_sgx.rb | 4 ++++ lib/customer.rb | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/backend_sgx.rb b/lib/backend_sgx.rb index 2bcb112479c0b8d72bf9245693219d24aa828425..7f386124027ec138f23bedd5a48d52be9ea41542 100644 --- a/lib/backend_sgx.rb +++ b/lib/backend_sgx.rb @@ -37,6 +37,10 @@ class BackendSgx REDIS.get("catapult_ogm_url-#{from_jid}") 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/customer.rb b/lib/customer.rb index b66858371fc897d0fbcd1d6d660a97fa12fd4562..b5b093c6ff008abff3a23c58a2b9221d28e28198 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -21,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 def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage def initialize( From 7d506e05e7d5e0a9e1c20c65bf2da4743caccd15 Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Wed, 15 Sep 2021 13:35:24 -0500 Subject: [PATCH 7/8] 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 From 94766da41ffc065551de1aa7a268edf4f379f6da Mon Sep 17 00:00:00 2001 From: Stephen Paul Weber Date: Mon, 20 Sep 2021 09:25:06 -0500 Subject: [PATCH 8/8] Support transcription disablement option --- lib/backend_sgx.rb | 9 +++++++++ lib/customer.rb | 2 +- views/voicemail.slim | 2 +- web.rb | 15 ++++++++++++--- 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/lib/backend_sgx.rb b/lib/backend_sgx.rb index 7f386124027ec138f23bedd5a48d52be9ea41542..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 @@ -37,6 +39,13 @@ class BackendSgx 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 diff --git a/lib/customer.rb b/lib/customer.rb index b5b093c6ff008abff3a23c58a2b9221d28e28198..a2916229e12f968ac7401daecb39d20fd1429138 100644 --- a/lib/customer.rb +++ b/lib/customer.rb @@ -22,7 +22,7 @@ class Customer def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan, :currency, :merchant_account, :plan_name, :auto_top_up_amount def_delegators :@sgx, :register!, :registered?, - :fwd_timeout, :set_fwd_timeout + :fwd_timeout, :set_fwd_timeout, :catapult_flag def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage def initialize( diff --git a/views/voicemail.slim b/views/voicemail.slim index 7d5310118cd7c6ebd86702d4e9ee140cd10eefee..85118ed8bb2f260263d1babfda18ede2eb381a33 100644 --- a/views/voicemail.slim +++ b/views/voicemail.slim @@ -4,7 +4,7 @@ Response == render(*ogm.to_render) PlayAudio= "/beep.mp3" Record{ - transcribe="true" + 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/web.rb b/web.rb index f55b987b6b9b6b0f4514850ad4b9e5239a8af2d7..84911dff2770b71a5f3bb232051ebcc9a5a731a3 100644 --- a/web.rb +++ b/web.rb @@ -288,9 +288,18 @@ class Web < Roda r.post do customer_repo .find_by_tel(params["to"]) - .then { |customer| customer.ogm(params["from"]) } - .then do |ogm| - render :voicemail, locals: { ogm: ogm } + .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