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