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 unless ENV["ENV"] == "test" # Must go first!
50 use Sentry::Rack::CaptureExceptions
51 plugin :json_parser
52 plugin :type_routing
53 plugin :public
54 plugin :render, engine: "slim"
55 plugin RodaCapture
56 plugin RodaEMPromise # Must go last!
57
58 class << self
59 attr_reader :customer_repo, :log, :outbound_transfers
60
61 def run(log, *listen_on)
62 plugin :common_logger, log, method: :info
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', :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 customer_repo(**kwargs)
102 opts[:customer_repo] || CustomerRepo.new(**kwargs)
103 end
104
105 TEL_CANDIDATES = {
106 "Restricted" => "14",
107 "anonymous" => "15",
108 "Anonymous" => "16",
109 "unavailable" => "17",
110 "Unavailable" => "18"
111 }.freeze
112
113 def sanitize_tel_candidate(candidate)
114 if candidate.length < 3
115 "13;phone-context=anonymous.phone-context.soprani.ca"
116 elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
117 candidate
118 else
119 "#{TEL_CANDIDATES.fetch(candidate, '19')}" \
120 ";phone-context=anonymous.phone-context.soprani.ca"
121 end
122 end
123
124 def from_jid
125 Blather::JID.new(
126 sanitize_tel_candidate(params["from"]),
127 CONFIG[:component][:jid]
128 )
129 end
130
131 def inbound_calls_path(suffix)
132 ["/inbound/calls/#{params['callId']}", suffix].compact.join("/")
133 end
134
135 def url(path)
136 "#{request.base_url}#{path}"
137 end
138
139 def modify_call(call_id)
140 body = Bandwidth::ApiModifyCallRequest.new
141 yield body
142 BANDWIDTH_VOICE.modify_call(
143 CONFIG[:creds][:account],
144 call_id,
145 body: body
146 )
147 end
148
149 route do |r|
150 r.on "inbound" do
151 r.on "calls" do
152 r.post "status" do
153 if params["eventType"] == "disconnect"
154 if (outbound_leg = outbound_transfers.delete(params["callId"]))
155 modify_call(outbound_leg) do |call|
156 call.state = "completed"
157 end
158 end
159
160 customer_repo.find_by_tel(params["to"]).then do |customer|
161 CDR.for_inbound(customer.customer_id, params).save
162 end
163 end
164 "OK"
165 end
166
167 r.on :call_id do |call_id|
168 r.post "transfer_complete" do
169 outbound_leg = outbound_transfers.delete(call_id)
170 if params["cause"] == "hangup"
171 log.info "Normal hangup, now end #{call_id}", loggable_params
172 modify_call(call_id) { |call| call.state = "completed" }
173 elsif !outbound_leg
174 log.debug "Inbound disconnected", loggable_params
175 else
176 log.debug "Go to voicemail", loggable_params
177 modify_call(call_id) do |call|
178 call.redirect_url = url inbound_calls_path(:voicemail)
179 end
180 end
181 ""
182 end
183
184 r.on "voicemail" do
185 r.post "audio" do
186 duration = Time.parse(params["endTime"]) -
187 Time.parse(params["startTime"])
188 next "OK<5" unless duration > 5
189
190 jmp_media_url = params["mediaUrl"].sub(
191 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
192 "https://jmp.chat"
193 )
194
195 customer_repo.find_by_tel(params["to"]).then do |customer|
196 m = Blather::Stanza::Message.new
197 m.chat_state = nil
198 m.from = from_jid
199 m.subject = "New Voicemail"
200 m.body = jmp_media_url
201 m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
202 customer.stanza_to(m)
203
204 "OK"
205 end
206 end
207
208 r.post "transcription" do
209 duration = Time.parse(params["endTime"]) -
210 Time.parse(params["startTime"])
211 next "OK<5" unless duration > 5
212
213 customer_repo.find_by_tel(params["to"]).then do |customer|
214 m = Blather::Stanza::Message.new
215 m.chat_state = nil
216 m.from = from_jid
217 m.subject = "Voicemail Transcription"
218 m.body = BANDWIDTH_VOICE.get_recording_transcription(
219 params["accountId"], params["callId"], params["recordingId"]
220 ).data.transcripts[0].text
221 customer.stanza_to(m)
222
223 "OK"
224 end
225 end
226
227 r.post do
228 customer_repo(sgx_repo: Bwmsgsv2Repo.new)
229 .find_by_tel(params["to"])
230 .then { |c|
231 EMPromise.all([c, c.ogm(params["from"])])
232 }.then do |(customer, ogm)|
233 render :voicemail, locals: {
234 ogm: ogm,
235 transcription_enabled: customer.transcription_enabled
236 }
237 end
238 end
239 end
240
241 r.post do
242 render :bridge, locals: { call_id: call_id }
243 end
244 end
245
246 r.post do
247 customer_repo(
248 sgx_repo: Bwmsgsv2Repo.new
249 ).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
250 call = fwd.create_call(CONFIG[:creds][:account]) { |cc|
251 cc.from = params["from"]
252 cc.application_id = params["applicationId"]
253 cc.answer_url = url inbound_calls_path(nil)
254 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
255 }
256
257 if call
258 outbound_transfers[params["callId"]] = call
259 render :ring, locals: { duration: 300 }
260 else
261 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
262 end
263 end
264 end
265 end
266 end
267
268 r.on "outbound" do
269 r.on "calls" do
270 r.post "status" do
271 log.info "#{params['eventType']} #{params['callId']}", loggable_params
272 if params["eventType"] == "disconnect"
273 CDR.for_outbound(params).save.catch(&method(:log_error))
274 end
275 "OK"
276 end
277
278 r.post do
279 from = params["from"].sub(/^\+1/, "")
280 customer_repo(
281 sgx_repo: Bwmsgsv2Repo.new
282 ).find_by_format(from).then do |c|
283 r.json do
284 {
285 from: c.registered?.phone,
286 to: params["to"]
287 }.to_json
288 end
289
290 render :forward, locals: {
291 from: c.registered?.phone,
292 to: params["to"]
293 }
294 end
295 end
296 end
297 end
298
299 r.on "ogm" do
300 r.post "start" do
301 render :record_ogm, locals: { customer_id: params["customer_id"] }
302 end
303
304 r.post do
305 jmp_media_url = params["mediaUrl"].sub(
306 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
307 "https://jmp.chat"
308 )
309 ogm = OGMDownload.new(jmp_media_url)
310 ogm.download.then do
311 File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
312 File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
313 customer_repo.find(params["customer_id"]).then do |customer|
314 customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
315 end
316 end
317 end
318 end
319
320 r.public
321 end
322end
323# rubocop:enable Metrics/ClassLength