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 { |c|
237 EMPromise.all([c, c.ogm(params["from"])])
238 }.then do |(customer, ogm)|
239 render :voicemail, locals: {
240 ogm: ogm,
241 transcription_enabled: customer.transcription_enabled
242 }
243 end
244 end
245 end
246
247 r.post do
248 true_call_id = true_inbound_call[pseudo_call_id]
249 render :bridge, locals: { call_id: true_call_id }
250 end
251 end
252
253 r.post do
254 if true_inbound_call[pseudo_call_id]
255 true_inbound_call[pseudo_call_id] = params["callId"]
256 return render :pause, locals: { duration: 300 }
257 end
258
259 CustomerRepo.new(
260 sgx_repo: Bwmsgsv2Repo.new
261 ).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
262 call = fwd.create_call(CONFIG[:creds][:account]) { |cc|
263 true_inbound_call[pseudo_call_id] = params["callId"]
264 cc.from = params["from"]
265 cc.application_id = params["applicationId"]
266 cc.answer_url = url inbound_calls_path(nil)
267 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
268 }
269
270 if call
271 outbound_transfers[pseudo_call_id] = call
272 render :pause, locals: { duration: 300 }
273 else
274 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
275 end
276 end
277 end
278 end
279 end
280
281 r.on "outbound" do
282 r.on "calls" do
283 r.post "status" do
284 log.info "#{params['eventType']} #{params['callId']}", loggable_params
285 if params["eventType"] == "disconnect"
286 CDR.for_outbound(params).save.catch(&method(:log_error))
287 end
288 "OK"
289 end
290
291 r.post do
292 customer_id = params["from"].sub(/^\+1/, "")
293 CustomerRepo.new(
294 sgx_repo: Bwmsgsv2Repo.new
295 ).find(customer_id).then do |c|
296 render :forward, locals: {
297 from: c.registered?.phone,
298 to: params["to"]
299 }
300 end
301 end
302 end
303 end
304
305 r.on "ogm" do
306 r.post "start" do
307 render :record_ogm, locals: { customer_id: params["customer_id"] }
308 end
309
310 r.post do
311 jmp_media_url = params["mediaUrl"].sub(
312 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
313 "https://jmp.chat"
314 )
315 ogm = OGMDownload.new(jmp_media_url)
316 ogm.download.then do
317 File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
318 File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
319 CustomerRepo.new.find(params["customer_id"]).then do |customer|
320 customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
321 end
322 end
323 end
324 end
325
326 r.public
327 end
328end
329# rubocop:enable Metrics/ClassLength