1# frozen_string_literal: true
2
3require "digest"
4require "forwardable"
5require "roda"
6require "thin"
7require "sentry-ruby"
8require "bandwidth"
9
10Faraday.default_adapter = :em_synchrony
11
12require_relative "lib/cdr"
13require_relative "lib/roda_capture"
14require_relative "lib/roda_em_promise"
15require_relative "lib/rack_fiber"
16
17BANDWIDTH_VOICE = Bandwidth::Client.new(
18 voice_basic_auth_user_name: CONFIG[:creds][:username],
19 voice_basic_auth_password: CONFIG[:creds][:password]
20).voice_client.client
21
22module CustomerFwd
23 def self.from_redis(redis, customer, tel)
24 EMPromise.all([
25 redis.get("catapult_fwd-#{tel}"),
26 customer.fwd_timeout
27 ]).then do |(fwd, stimeout)|
28 timeout = Timeout.new(stimeout)
29 next if !fwd || timeout.zero?
30 self.for(fwd, timeout)
31 end
32 end
33
34 def self.for(uri, timeout)
35 case uri
36 when /^tel:/
37 Tel.new(uri, timeout)
38 when /^sip:/
39 SIP.new(uri, timeout)
40 when /^xmpp:/
41 XMPP.new(uri, timeout)
42 else
43 raise "Unknown forward URI: #{uri}"
44 end
45 end
46
47 class Timeout
48 def initialize(s)
49 @timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i
50 end
51
52 def zero?
53 @timeout.zero?
54 end
55
56 def to_i
57 @timeout
58 end
59 end
60
61 class Tel
62 attr_reader :timeout
63
64 def initialize(uri, timeout)
65 @tel = uri.sub(/^tel:/, "")
66 @timeout = timeout
67 end
68
69 def to
70 @tel
71 end
72 end
73
74 class SIP
75 attr_reader :timeout
76
77 def initialize(uri, timeout)
78 @uri = uri
79 @timeout = timeout
80 end
81
82 def to
83 @uri
84 end
85 end
86
87 class XMPP
88 attr_reader :timeout
89
90 def initialize(uri, timeout)
91 @jid = uri.sub(/^xmpp:/, "")
92 @timeout = timeout
93 end
94
95 def to
96 "sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com"
97 end
98 end
99end
100
101# rubocop:disable Metrics/ClassLength
102class Web < Roda
103 use Rack::Fiber # Must go first!
104 use Sentry::Rack::CaptureExceptions
105 plugin :json_parser
106 plugin :public
107 plugin :render, engine: "slim"
108 plugin RodaCapture
109 plugin RodaEMPromise # Must go last!
110
111 class << self
112 attr_reader :customer_repo, :log
113 attr_reader :true_inbound_call, :outbound_transfers
114
115 def run(log, customer_repo, *listen_on)
116 plugin :common_logger, log, method: :info
117 @customer_repo = customer_repo
118 @true_inbound_call = {}
119 @outbound_transfers = {}
120 Thin::Logging.logger = log
121 Thin::Server.start(
122 *listen_on,
123 freeze.app,
124 signals: false
125 )
126 end
127 end
128
129 extend Forwardable
130 def_delegators :'self.class', :customer_repo, :true_inbound_call,
131 :outbound_transfers
132 def_delegators :request, :params
133
134 def log
135 opts[:common_logger]
136 end
137
138 def log_error(e)
139 log.error(
140 "Error raised during #{request.full_path}: #{e.class}",
141 e,
142 loggable_params
143 )
144 if e.is_a?(::Exception)
145 Sentry.capture_exception(e)
146 else
147 Sentry.capture_message(e.to_s)
148 end
149 end
150
151 def loggable_params
152 params.dup.tap do |p|
153 p.delete("to")
154 p.delete("from")
155 end
156 end
157
158 def pseudo_call_id
159 request.captures_hash[:pseudo_call_id] ||
160 Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
161 end
162
163 TEL_CANDIDATES = {
164 "Restricted" => "14",
165 "anonymous" => "15",
166 "Anonymous" => "16",
167 "unavailable" => "17",
168 "Unavailable" => "18"
169 }.freeze
170
171 def sanitize_tel_candidate(candidate)
172 if candidate.length < 3
173 "13;phone-context=anonymous.phone-context.soprani.ca"
174 elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
175 candidate
176 elsif candidate == "Restricted"
177 TEL_CANDIDATES.fetch(candidate, "19") +
178 ";phone-context=anonymous.phone-context.soprani.ca"
179 end
180 end
181
182 def from_jid
183 Blather::JID.new(
184 sanitize_tel_candidate(params["from"]),
185 CONFIG[:component][:jid]
186 )
187 end
188
189 def inbound_calls_path(suffix)
190 ["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
191 end
192
193 def url(path)
194 "#{request.base_url}#{path}"
195 end
196
197 def modify_call(call_id)
198 body = Bandwidth::ApiModifyCallRequest.new
199 yield body
200 BANDWIDTH_VOICE.modify_call(
201 CONFIG[:creds][:account],
202 call_id,
203 body: body
204 )
205 end
206
207 route do |r|
208 r.on "inbound" do
209 r.on "calls" do
210 r.post "status" do
211 if params["eventType"] == "disconnect"
212 p_call_id = pseudo_call_id
213 call_id = params["callId"]
214 EM.promise_timer(2).then {
215 next unless true_inbound_call[p_call_id] == call_id
216 true_inbound_call.delete(p_call_id)
217
218 if (outbound_leg = outbound_transfers.delete(p_call_id))
219 modify_call(outbound_leg) do |call|
220 call.state = "completed"
221 end
222 end
223
224 customer_repo.find_by_tel(params["to"]).then do |customer|
225 CDR.for_inbound(customer.customer_id, params).save
226 end
227 }.catch(&method(:log_error))
228 end
229 "OK"
230 end
231
232 r.on :pseudo_call_id do |pseudo_call_id|
233 r.post "transfer_complete" do
234 outbound_leg = outbound_transfers.delete(pseudo_call_id)
235 if params["cause"] == "hangup"
236 log.debug "Normal hangup", loggable_params
237 elsif !outbound_leg
238 log.debug "Inbound disconnected", loggable_params
239 else
240 log.debug "Go to voicemail", loggable_params
241 true_call_id = true_inbound_call[pseudo_call_id]
242 modify_call(true_call_id) do |call|
243 call.redirect_url = url inbound_calls_path(:voicemail)
244 end
245 end
246 ""
247 end
248
249 r.on "voicemail" do
250 r.post "audio" do
251 duration = Time.parse(params["endTime"]) -
252 Time.parse(params["startTime"])
253 next "OK<5" unless duration > 5
254
255 jmp_media_url = params["mediaUrl"].sub(
256 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
257 "https://jmp.chat"
258 )
259
260 customer_repo.find_by_tel(params["to"]).then do |customer|
261 m = Blather::Stanza::Message.new
262 m.chat_state = nil
263 m.from = from_jid
264 m.subject = "New Voicemail"
265 m.body = jmp_media_url
266 m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
267 customer.stanza_to(m)
268
269 "OK"
270 end
271 end
272
273 r.post "transcription" do
274 customer_repo.find_by_tel(params["to"]).then do |customer|
275 m = Blather::Stanza::Message.new
276 m.chat_state = nil
277 m.from = from_jid
278 m.subject = "Voicemail Transcription"
279 m.body = BANDWIDTH_VOICE.get_recording_transcription(
280 params["accountId"], params["callId"], params["recordingId"]
281 ).data.transcripts[0].text
282 customer.stanza_to(m)
283
284 "OK"
285 end
286 end
287
288 r.post do
289 customer_repo
290 .find_by_tel(params["to"])
291 .then { |customer|
292 EMPromise.all([
293 customer.ogm(params["from"]),
294 customer.catapult_flag(
295 BackendSgx::VOICEMAIL_TRANSCRIPTION_DISABLED
296 )
297 ])
298 }.then do |(ogm, transcription_disabled)|
299 render :voicemail, locals: {
300 ogm: ogm,
301 transcription_enabled: !transcription_disabled
302 }
303 end
304 end
305 end
306
307 r.post do
308 true_call_id = true_inbound_call[pseudo_call_id]
309 render :bridge, locals: { call_id: true_call_id }
310 end
311 end
312
313 r.post do
314 if true_inbound_call[pseudo_call_id]
315 true_inbound_call[pseudo_call_id] = params["callId"]
316 return render :pause, locals: { duration: 300 }
317 end
318
319 customer_repo.find_by_tel(params["to"]).then do |customer|
320 CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd|
321 if fwd
322 body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
323 cc.to = fwd.to
324 cc.from = params["from"]
325 cc.application_id = params["applicationId"]
326 cc.call_timeout = fwd.timeout.to_i
327 cc.answer_url = url inbound_calls_path(nil)
328 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
329 end
330 true_inbound_call[pseudo_call_id] = params["callId"]
331 outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
332 CONFIG[:creds][:account], body: body
333 ).data.call_id
334 render :pause, locals: { duration: 300 }
335 else
336 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
337 end
338 end
339 end
340 end
341 end
342 end
343
344 r.on "outbound" do
345 r.on "calls" do
346 r.post "status" do
347 log.info "#{params['eventType']} #{params['callId']}", loggable_params
348 if params["eventType"] == "disconnect"
349 CDR.for_outbound(params).save.catch(&method(:log_error))
350 end
351 "OK"
352 end
353
354 r.post do
355 customer_id = params["from"].sub(/^\+1/, "")
356 customer_repo.find(customer_id).then(:registered?).then do |reg|
357 render :forward, locals: {
358 from: reg.phone,
359 to: params["to"]
360 }
361 end
362 end
363 end
364 end
365
366 r.public
367 end
368end
369# rubocop:enable Metrics/ClassLength