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