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
59 attr_reader :true_inbound_call, :outbound_transfers
60
61 def run(log, *listen_on)
62 plugin :common_logger, log, method: :info
63 @true_inbound_call = {}
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', :true_inbound_call, :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 pseudo_call_id
103 request.captures_hash[:pseudo_call_id] ||
104 Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
105 end
106
107 TEL_CANDIDATES = {
108 "Restricted" => "14",
109 "anonymous" => "15",
110 "Anonymous" => "16",
111 "unavailable" => "17",
112 "Unavailable" => "18"
113 }.freeze
114
115 def sanitize_tel_candidate(candidate)
116 if candidate.length < 3
117 "13;phone-context=anonymous.phone-context.soprani.ca"
118 elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
119 candidate
120 elsif candidate == "Restricted"
121 TEL_CANDIDATES.fetch(candidate, "19") +
122 ";phone-context=anonymous.phone-context.soprani.ca"
123 end
124 end
125
126 def from_jid
127 Blather::JID.new(
128 sanitize_tel_candidate(params["from"]),
129 CONFIG[:component][:jid]
130 )
131 end
132
133 def inbound_calls_path(suffix)
134 ["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
135 end
136
137 def url(path)
138 "#{request.base_url}#{path}"
139 end
140
141 def modify_call(call_id)
142 body = Bandwidth::ApiModifyCallRequest.new
143 yield body
144 BANDWIDTH_VOICE.modify_call(
145 CONFIG[:creds][:account],
146 call_id,
147 body: body
148 )
149 end
150
151 route do |r|
152 r.on "inbound" do
153 r.on "calls" do
154 r.post "status" do
155 if params["eventType"] == "disconnect"
156 p_call_id = pseudo_call_id
157 call_id = params["callId"]
158 EM.promise_timer(2).then {
159 next unless true_inbound_call[p_call_id] == call_id
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 if fwd
261 true_inbound_call[pseudo_call_id] = params["callId"]
262 outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
263 CONFIG[:creds][:account],
264 body: fwd.create_call_request do |cc|
265 cc.from = params["from"]
266 cc.application_id = params["applicationId"]
267 cc.answer_url = url inbound_calls_path(nil)
268 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
269 end
270 ).data.call_id
271 render :pause, locals: { duration: 300 }
272 else
273 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
274 end
275 end
276 end
277 end
278 end
279
280 r.on "outbound" do
281 r.on "calls" do
282 r.post "status" do
283 log.info "#{params['eventType']} #{params['callId']}", loggable_params
284 if params["eventType"] == "disconnect"
285 CDR.for_outbound(params).save.catch(&method(:log_error))
286 end
287 "OK"
288 end
289
290 r.post do
291 customer_id = params["from"].sub(/^\+1/, "")
292 CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find(customer_id).then do |c|
293 render :forward, locals: {
294 from: c.registered?.phone,
295 to: params["to"]
296 }
297 end
298 end
299 end
300 end
301
302 r.on "ogm" do
303 r.post "start" do
304 render :record_ogm, locals: { customer_id: params["customer_id"] }
305 end
306
307 r.post do
308 jmp_media_url = params["mediaUrl"].sub(
309 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
310 "https://jmp.chat"
311 )
312 ogm = OGMDownload.new(jmp_media_url)
313 ogm.download.then do
314 File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
315 CustomerRepo.new.find(params["customer_id"]).then do |customer|
316 customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
317 end
318 end
319 end
320 end
321
322 r.public
323 end
324end
325# rubocop:enable Metrics/ClassLength