# 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