1# frozen_string_literal: true
2
3require "digest"
4require "forwardable"
5require "multibases"
6require "multihashes"
7require "roda"
8require "thin"
9require "sentry-ruby"
10
11require_relative "lib/call_attempt_repo"
12require_relative "lib/cdr"
13require_relative "lib/oob"
14require_relative "lib/roda_capture"
15require_relative "lib/roda_em_promise"
16require_relative "lib/rack_fiber"
17
18class OGMDownload
19 def initialize(url)
20 @digest = Digest::SHA512.new
21 @f = Tempfile.open("ogm")
22 @req = EM::HttpRequest.new(url, tls: { verify_peer: true })
23 end
24
25 def download
26 http = @req.aget
27 http.stream do |chunk|
28 @digest << chunk
29 @f.write chunk
30 end
31 http.then { @f.close }.catch do |e|
32 @f.close!
33 EMPromise.reject(e)
34 end
35 end
36
37 def cid
38 Multibases.encode(
39 "base58btc",
40 [1, 85].pack("C*") + Multihashes.encode(@digest.digest, "sha2-512")
41 ).pack.to_s
42 end
43
44 def path
45 @f.path
46 end
47end
48
49# rubocop:disable Metrics/ClassLength
50class Web < Roda
51 use Rack::Fiber unless ENV["ENV"] == "test" # Must go first!
52 use Sentry::Rack::CaptureExceptions
53 plugin :json_parser
54 plugin :type_routing
55 plugin :public
56 plugin :render, engine: "slim"
57 plugin RodaCapture
58 plugin RodaEMPromise # Must go last!
59
60 class << self
61 attr_reader :customer_repo, :log, :outbound_transfers
62
63 def run(log, *listen_on)
64 plugin :common_logger, log, method: :info
65 @outbound_transfers = {}
66 Thin::Logging.logger = log
67 Thin::Server.start(
68 *listen_on,
69 freeze.app,
70 signals: false
71 )
72 end
73 end
74
75 extend Forwardable
76 def_delegators :'self.class', :outbound_transfers
77 def_delegators :request, :params
78
79 def log
80 opts[:common_logger]
81 end
82
83 def log_error(e)
84 log.error(
85 "Error raised during #{request.fullpath}: #{e.class}",
86 e,
87 loggable_params
88 )
89 if e.is_a?(::Exception)
90 Sentry.capture_exception(e)
91 else
92 Sentry.capture_message(e.to_s)
93 end
94 end
95
96 def loggable_params
97 params.dup.tap do |p|
98 p.delete("to")
99 p.delete("from")
100 end
101 end
102
103 def customer_repo(**kwargs)
104 opts[:customer_repo] || CustomerRepo.new(**kwargs)
105 end
106
107 def call_attempt_repo
108 opts[:call_attempt_repo] || CallAttemptRepo.new
109 end
110
111 TEL_CANDIDATES = {
112 "Restricted" => "14",
113 "anonymous" => "15",
114 "Anonymous" => "16",
115 "unavailable" => "17",
116 "Unavailable" => "18"
117 }.freeze
118
119 def sanitize_tel_candidate(candidate)
120 if candidate.length < 3
121 "13;phone-context=anonymous.phone-context.soprani.ca"
122 elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
123 candidate
124 else
125 "#{TEL_CANDIDATES.fetch(candidate, '19')}" \
126 ";phone-context=anonymous.phone-context.soprani.ca"
127 end
128 end
129
130 def from_jid
131 Blather::JID.new(
132 sanitize_tel_candidate(params["from"]),
133 CONFIG[:component][:jid]
134 )
135 end
136
137 def inbound_calls_path(suffix, customer_id=nil)
138 ["/inbound/calls/#{params['callId']}", suffix].compact.join("/") +
139 (customer_id ? "?customer_id=#{customer_id}" : "")
140 end
141
142 def url(path)
143 "#{request.base_url}#{path}"
144 end
145
146 def modify_call(call_id)
147 body = Bandwidth::ApiModifyCallRequest.new
148 yield body
149 BANDWIDTH_VOICE.modify_call(
150 CONFIG[:creds][:account],
151 call_id,
152 body: body
153 )
154 rescue Bandwidth::ApiErrorResponseException
155 # If call does not exist, don't need to hang up or send to voicemail
156 # Other side must have hung up already
157 raise $! unless $!.response_code.to_s == "404"
158 end
159
160 route do |r|
161 r.on "inbound" do
162 r.on "calls" do
163 r.post "status" do
164 if params["eventType"] == "disconnect"
165 if (outbound_leg = outbound_transfers.delete(params["callId"]))
166 modify_call(outbound_leg) do |call|
167 call.state = "completed"
168 end
169 end
170
171 customer_repo.find_by_tel(params["to"]).then do |customer|
172 Sentry.set_user(id: customer.customer_id)
173 CDR.for_inbound(customer.customer_id, params).save
174 end
175 end
176 "OK"
177 end
178
179 r.on :call_id do |call_id|
180 r.post "transfer_complete" do
181 outbound_leg = outbound_transfers.delete(call_id)
182 if params["cause"] == "hangup" && params["tag"] == "connected"
183 log.info "Normal hangup, now end #{call_id}", loggable_params
184 modify_call(call_id) { |call| call.state = "completed" }
185 elsif !outbound_leg
186 log.debug "Inbound disconnected", loggable_params
187 else
188 log.debug "Go to voicemail", loggable_params
189 modify_call(call_id) do |call|
190 call.redirect_url = url inbound_calls_path(:voicemail)
191 end
192 end
193 ""
194 end
195
196 r.on "voicemail" do
197 r.post "audio" do
198 duration = Time.parse(params["endTime"]) -
199 Time.parse(params["startTime"])
200 next "OK<5" unless duration > 5
201
202 jmp_media_url = params["mediaUrl"].sub(
203 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
204 "https://jmp.chat"
205 )
206
207 customer_repo.find_by_tel(params["to"]).then do |customer|
208 Sentry.set_user(id: customer.customer_id)
209 m = Blather::Stanza::Message.new
210 m.chat_state = nil
211 m.from = from_jid
212 m.subject = "New Voicemail"
213 m.body = jmp_media_url
214 m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
215 customer.stanza_to(m)
216
217 "OK"
218 end
219 end
220
221 r.post "transcription" do
222 duration = Time.parse(params["endTime"]) -
223 Time.parse(params["startTime"])
224 next "OK<5" unless duration > 5
225
226 customer_repo.find_by_tel(params["to"]).then do |customer|
227 Sentry.set_user(id: customer.customer_id)
228 m = Blather::Stanza::Message.new
229 m.chat_state = nil
230 m.from = from_jid
231 m.subject = "Voicemail Transcription"
232 m.body = BANDWIDTH_VOICE.get_recording_transcription(
233 params["accountId"], params["callId"], params["recordingId"]
234 ).data.transcripts[0].text
235 customer.stanza_to(m)
236
237 "OK"
238 end
239 end
240
241 r.post do
242 customer_repo(sgx_repo: Bwmsgsv2Repo.new)
243 .find_by_tel(params["to"])
244 .then { |c|
245 Sentry.set_user(id: c.customer_id)
246 EMPromise.all([c, c.ogm(params["from"])])
247 }.then do |(customer, ogm)|
248 render :voicemail, locals: {
249 ogm: ogm,
250 transcription_enabled: customer.transcription_enabled
251 }
252 end
253 end
254 end
255
256 r.post do
257 customer_repo(
258 sgx_repo: Bwmsgsv2Repo.new
259 ).find(params.fetch("customer_id")).then do |customer|
260 Sentry.set_user(id: customer.customer_id)
261 call_attempt_repo.find_inbound(
262 customer,
263 params["from"],
264 call_id: call_id,
265 digits: params["digits"]
266 ).then { |ca| render(*ca.to_render) }
267 end
268 end
269 end
270
271 r.post do
272 Sentry.set_user(tel: params["to"])
273 customer_repo(
274 sgx_repo: Bwmsgsv2Repo.new
275 ).find_by_tel(params["to"]).then { |customer|
276 Sentry.set_user(id: customer.customer_id)
277 EMPromise.all([
278 customer.customer_id, customer.fwd,
279 call_attempt_repo.find_inbound(
280 customer, params["from"], call_id: params["callId"]
281 )
282 ])
283 }.then do |(customer_id, fwd, ca)|
284 call = ca.create_call(fwd, CONFIG[:creds][:account]) { |cc|
285 cc.from = params["from"]
286 cc.application_id = params["applicationId"]
287 cc.answer_url = url inbound_calls_path(nil, customer_id)
288 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
289 }
290
291 if call
292 outbound_transfers[params["callId"]] = call
293 render :ring, locals: { duration: 300 }
294 else
295 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
296 end
297 end
298 end
299 end
300 end
301
302 r.on "outbound" do
303 r.on "calls" do
304 r.post "status" do
305 log.info "#{params['eventType']} #{params['callId']}", loggable_params
306 if params["eventType"] == "disconnect"
307 CDR.for_outbound(params).save.catch(&method(:log_error))
308 end
309 "OK"
310 end
311
312 r.post do
313 from = params["from"].sub(/^\+1/, "")
314 customer_repo(
315 sgx_repo: Bwmsgsv2Repo.new
316 ).find_by_format(from).then do |c|
317 Sentry.set_user(id: c.customer_id)
318 call_attempt_repo.find_outbound(
319 c,
320 params["to"],
321 call_id: params["callId"],
322 digits: params["digits"]
323 ).then do |ca|
324 r.json { ca.to_json }
325
326 render(*ca.to_render)
327 end
328 end
329 end
330 end
331 end
332
333 r.on "ogm" do
334 r.post "start" do
335 render :record_ogm, locals: { customer_id: params["customer_id"] }
336 end
337
338 r.post do
339 jmp_media_url = params["mediaUrl"].sub(
340 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
341 "https://jmp.chat"
342 )
343 ogm = OGMDownload.new(jmp_media_url)
344 ogm.download.then do
345 File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
346 File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
347 customer_repo.find(params["customer_id"]).then do |customer|
348 customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
349 end
350 end
351 end
352 end
353
354 r.public
355 end
356end
357# rubocop:enable Metrics/ClassLength