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