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, :outbound_transfers
59
60 def run(log, *listen_on)
61 plugin :common_logger, log, method: :info
62 @outbound_transfers = {}
63 Thin::Logging.logger = log
64 Thin::Server.start(
65 *listen_on,
66 freeze.app,
67 signals: false
68 )
69 end
70 end
71
72 extend Forwardable
73 def_delegators :'self.class', :outbound_transfers
74 def_delegators :request, :params
75
76 def log
77 opts[:common_logger]
78 end
79
80 def log_error(e)
81 log.error(
82 "Error raised during #{request.full_path}: #{e.class}",
83 e,
84 loggable_params
85 )
86 if e.is_a?(::Exception)
87 Sentry.capture_exception(e)
88 else
89 Sentry.capture_message(e.to_s)
90 end
91 end
92
93 def loggable_params
94 params.dup.tap do |p|
95 p.delete("to")
96 p.delete("from")
97 end
98 end
99
100 TEL_CANDIDATES = {
101 "Restricted" => "14",
102 "anonymous" => "15",
103 "Anonymous" => "16",
104 "unavailable" => "17",
105 "Unavailable" => "18"
106 }.freeze
107
108 def sanitize_tel_candidate(candidate)
109 if candidate.length < 3
110 "13;phone-context=anonymous.phone-context.soprani.ca"
111 elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
112 candidate
113 elsif candidate == "Restricted"
114 "#{TEL_CANDIDATES.fetch(candidate, '19')}" \
115 ";phone-context=anonymous.phone-context.soprani.ca"
116 end
117 end
118
119 def from_jid
120 Blather::JID.new(
121 sanitize_tel_candidate(params["from"]),
122 CONFIG[:component][:jid]
123 )
124 end
125
126 def inbound_calls_path(suffix)
127 ["/inbound/calls/#{params['callId']}", suffix].compact.join("/")
128 end
129
130 def url(path)
131 "#{request.base_url}#{path}"
132 end
133
134 def modify_call(call_id)
135 body = Bandwidth::ApiModifyCallRequest.new
136 yield body
137 BANDWIDTH_VOICE.modify_call(
138 CONFIG[:creds][:account],
139 call_id,
140 body: body
141 )
142 end
143
144 route do |r|
145 r.on "inbound" do
146 r.on "calls" do
147 r.post "status" do
148 if params["eventType"] == "disconnect"
149 if (outbound_leg = outbound_transfers.delete(params["callId"]))
150 modify_call(outbound_leg) do |call|
151 call.state = "completed"
152 end
153 end
154
155 CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
156 CDR.for_inbound(customer.customer_id, params).save
157 end
158 end
159 "OK"
160 end
161
162 r.on :call_id do |call_id|
163 r.post "transfer_complete" do
164 outbound_leg = outbound_transfers.delete(call_id)
165 if params["cause"] == "hangup"
166 log.debug "Normal hangup", loggable_params
167 elsif !outbound_leg
168 log.debug "Inbound disconnected", loggable_params
169 else
170 log.debug "Go to voicemail", loggable_params
171 modify_call(call_id) do |call|
172 call.redirect_url = url inbound_calls_path(:voicemail)
173 end
174 end
175 ""
176 end
177
178 r.on "voicemail" do
179 r.post "audio" do
180 duration = Time.parse(params["endTime"]) -
181 Time.parse(params["startTime"])
182 next "OK<5" unless duration > 5
183
184 jmp_media_url = params["mediaUrl"].sub(
185 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
186 "https://jmp.chat"
187 )
188
189 CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
190 m = Blather::Stanza::Message.new
191 m.chat_state = nil
192 m.from = from_jid
193 m.subject = "New Voicemail"
194 m.body = jmp_media_url
195 m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
196 customer.stanza_to(m)
197
198 "OK"
199 end
200 end
201
202 r.post "transcription" do
203 CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
204 m = Blather::Stanza::Message.new
205 m.chat_state = nil
206 m.from = from_jid
207 m.subject = "Voicemail Transcription"
208 m.body = BANDWIDTH_VOICE.get_recording_transcription(
209 params["accountId"], params["callId"], params["recordingId"]
210 ).data.transcripts[0].text
211 customer.stanza_to(m)
212
213 "OK"
214 end
215 end
216
217 r.post do
218 CustomerRepo
219 .new(sgx_repo: Bwmsgsv2Repo.new)
220 .find_by_tel(params["to"])
221 .then { |c|
222 EMPromise.all([c, c.ogm(params["from"])])
223 }.then do |(customer, ogm)|
224 render :voicemail, locals: {
225 ogm: ogm,
226 transcription_enabled: customer.transcription_enabled
227 }
228 end
229 end
230 end
231
232 r.post do
233 render :bridge, locals: { call_id: call_id }
234 end
235 end
236
237 r.post do
238 CustomerRepo.new(
239 sgx_repo: Bwmsgsv2Repo.new
240 ).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
241 call = fwd.create_call(CONFIG[:creds][:account]) { |cc|
242 cc.from = params["from"]
243 cc.application_id = params["applicationId"]
244 cc.answer_url = url inbound_calls_path(nil)
245 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
246 }
247
248 if call
249 outbound_transfers[params["callId"]] = call
250 render :ring, locals: { duration: 300 }
251 else
252 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
253 end
254 end
255 end
256 end
257 end
258
259 r.on "outbound" do
260 r.on "calls" do
261 r.post "status" do
262 log.info "#{params['eventType']} #{params['callId']}", loggable_params
263 if params["eventType"] == "disconnect"
264 CDR.for_outbound(params).save.catch(&method(:log_error))
265 end
266 "OK"
267 end
268
269 r.post do
270 customer_id = params["from"].sub(/^\+1/, "")
271 CustomerRepo.new(
272 sgx_repo: Bwmsgsv2Repo.new
273 ).find(customer_id).then do |c|
274 render :forward, locals: {
275 from: c.registered?.phone,
276 to: params["to"]
277 }
278 end
279 end
280 end
281 end
282
283 r.on "ogm" do
284 r.post "start" do
285 render :record_ogm, locals: { customer_id: params["customer_id"] }
286 end
287
288 r.post do
289 jmp_media_url = params["mediaUrl"].sub(
290 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
291 "https://jmp.chat"
292 )
293 ogm = OGMDownload.new(jmp_media_url)
294 ogm.download.then do
295 File.rename(ogm.path, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
296 File.chmod(0o644, "#{CONFIG[:ogm_path]}/#{ogm.cid}")
297 CustomerRepo.new.find(params["customer_id"]).then do |customer|
298 customer.set_ogm_url("#{CONFIG[:ogm_web_root]}/#{ogm.cid}.mp3")
299 end
300 end
301 end
302 end
303
304 r.public
305 end
306end
307# rubocop:enable Metrics/ClassLength