1# frozen_string_literal: true
2
3require "digest"
4require "forwardable"
5require "roda"
6require "thin"
7require "sentry-ruby"
8
9require_relative "lib/cdr"
10require_relative "lib/roda_capture"
11require_relative "lib/roda_em_promise"
12require_relative "lib/rack_fiber"
13
14# rubocop:disable Metrics/ClassLength
15class Web < Roda
16 use Rack::Fiber # Must go first!
17 use Sentry::Rack::CaptureExceptions
18 plugin :json_parser
19 plugin :public
20 plugin :render, engine: "slim"
21 plugin RodaCapture
22 plugin RodaEMPromise # Must go last!
23
24 class << self
25 attr_reader :customer_repo, :log
26 attr_reader :true_inbound_call, :outbound_transfers
27
28 def run(log, *listen_on)
29 plugin :common_logger, log, method: :info
30 @true_inbound_call = {}
31 @outbound_transfers = {}
32 Thin::Logging.logger = log
33 Thin::Server.start(
34 *listen_on,
35 freeze.app,
36 signals: false
37 )
38 end
39 end
40
41 extend Forwardable
42 def_delegators :'self.class', :true_inbound_call, :outbound_transfers
43 def_delegators :request, :params
44
45 def log
46 opts[:common_logger]
47 end
48
49 def log_error(e)
50 log.error(
51 "Error raised during #{request.full_path}: #{e.class}",
52 e,
53 loggable_params
54 )
55 if e.is_a?(::Exception)
56 Sentry.capture_exception(e)
57 else
58 Sentry.capture_message(e.to_s)
59 end
60 end
61
62 def loggable_params
63 params.dup.tap do |p|
64 p.delete("to")
65 p.delete("from")
66 end
67 end
68
69 def pseudo_call_id
70 request.captures_hash[:pseudo_call_id] ||
71 Digest::SHA256.hexdigest("#{params['from']},#{params['to']}")
72 end
73
74 TEL_CANDIDATES = {
75 "Restricted" => "14",
76 "anonymous" => "15",
77 "Anonymous" => "16",
78 "unavailable" => "17",
79 "Unavailable" => "18"
80 }.freeze
81
82 def sanitize_tel_candidate(candidate)
83 if candidate.length < 3
84 "13;phone-context=anonymous.phone-context.soprani.ca"
85 elsif candidate[0] == "+" && /\A\d+\z/.match(candidate[1..-1])
86 candidate
87 elsif candidate == "Restricted"
88 TEL_CANDIDATES.fetch(candidate, "19") +
89 ";phone-context=anonymous.phone-context.soprani.ca"
90 end
91 end
92
93 def from_jid
94 Blather::JID.new(
95 sanitize_tel_candidate(params["from"]),
96 CONFIG[:component][:jid]
97 )
98 end
99
100 def inbound_calls_path(suffix)
101 ["/inbound/calls/#{pseudo_call_id}", suffix].compact.join("/")
102 end
103
104 def url(path)
105 "#{request.base_url}#{path}"
106 end
107
108 def modify_call(call_id)
109 body = Bandwidth::ApiModifyCallRequest.new
110 yield body
111 BANDWIDTH_VOICE.modify_call(
112 CONFIG[:creds][:account],
113 call_id,
114 body: body
115 )
116 end
117
118 route do |r|
119 r.on "inbound" do
120 r.on "calls" do
121 r.post "status" do
122 if params["eventType"] == "disconnect"
123 p_call_id = pseudo_call_id
124 call_id = params["callId"]
125 EM.promise_timer(2).then {
126 next unless true_inbound_call[p_call_id] == call_id
127 true_inbound_call.delete(p_call_id)
128
129 if (outbound_leg = outbound_transfers.delete(p_call_id))
130 modify_call(outbound_leg) do |call|
131 call.state = "completed"
132 end
133 end
134
135 CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
136 CDR.for_inbound(customer.customer_id, params).save
137 end
138 }.catch(&method(:log_error))
139 end
140 "OK"
141 end
142
143 r.on :pseudo_call_id do |pseudo_call_id|
144 r.post "transfer_complete" do
145 outbound_leg = outbound_transfers.delete(pseudo_call_id)
146 if params["cause"] == "hangup"
147 log.debug "Normal hangup", loggable_params
148 elsif !outbound_leg
149 log.debug "Inbound disconnected", loggable_params
150 else
151 log.debug "Go to voicemail", loggable_params
152 true_call_id = true_inbound_call[pseudo_call_id]
153 modify_call(true_call_id) do |call|
154 call.redirect_url = url inbound_calls_path(:voicemail)
155 end
156 end
157 ""
158 end
159
160 r.on "voicemail" do
161 r.post "audio" do
162 duration = Time.parse(params["endTime"]) -
163 Time.parse(params["startTime"])
164 next "OK<5" unless duration > 5
165
166 jmp_media_url = params["mediaUrl"].sub(
167 /\Ahttps:\/\/voice.bandwidth.com\/api\/v2\/accounts\/\d+/,
168 "https://jmp.chat"
169 )
170
171 CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
172 m = Blather::Stanza::Message.new
173 m.chat_state = nil
174 m.from = from_jid
175 m.subject = "New Voicemail"
176 m.body = jmp_media_url
177 m << OOB.new(jmp_media_url, desc: "Voicemail Recording")
178 customer.stanza_to(m)
179
180 "OK"
181 end
182 end
183
184 r.post "transcription" do
185 CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
186 m = Blather::Stanza::Message.new
187 m.chat_state = nil
188 m.from = from_jid
189 m.subject = "Voicemail Transcription"
190 m.body = BANDWIDTH_VOICE.get_recording_transcription(
191 params["accountId"], params["callId"], params["recordingId"]
192 ).data.transcripts[0].text
193 customer.stanza_to(m)
194
195 "OK"
196 end
197 end
198
199 r.post do
200 CustomerRepo
201 .new(sgx_repo: Bwmsgsv2Repo.new)
202 .find_by_tel(params["to"])
203 .then do |customer|
204 render :voicemail, locals: {
205 ogm: customer.ogm(params["from"]),
206 transcription_enabled: customer.transcription_enabled
207 }
208 end
209 end
210 end
211
212 r.post do
213 true_call_id = true_inbound_call[pseudo_call_id]
214 render :bridge, locals: { call_id: true_call_id }
215 end
216 end
217
218 r.post do
219 if true_inbound_call[pseudo_call_id]
220 true_inbound_call[pseudo_call_id] = params["callId"]
221 return render :pause, locals: { duration: 300 }
222 end
223
224 CustomerRepo.new(
225 sgx_repo: Bwmsgsv2Repo.new
226 ).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
227 if fwd
228 body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
229 cc.to = fwd.to
230 cc.from = params["from"]
231 cc.application_id = params["applicationId"]
232 cc.call_timeout = fwd.timeout.to_i
233 cc.answer_url = url inbound_calls_path(nil)
234 cc.disconnect_url = url inbound_calls_path(:transfer_complete)
235 end
236 true_inbound_call[pseudo_call_id] = params["callId"]
237 outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
238 CONFIG[:creds][:account], body: body
239 ).data.call_id
240 render :pause, locals: { duration: 300 }
241 else
242 render :redirect, locals: { to: inbound_calls_path(:voicemail) }
243 end
244 end
245 end
246 end
247 end
248
249 r.on "outbound" do
250 r.on "calls" do
251 r.post "status" do
252 log.info "#{params['eventType']} #{params['callId']}", loggable_params
253 if params["eventType"] == "disconnect"
254 CDR.for_outbound(params).save.catch(&method(:log_error))
255 end
256 "OK"
257 end
258
259 r.post do
260 customer_id = params["from"].sub(/^\+1/, "")
261 CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find(customer_id).then do |c|
262 render :forward, locals: {
263 from: c.registered?.phone,
264 to: params["to"]
265 }
266 end
267 end
268 end
269 end
270
271 r.public
272 end
273end
274# rubocop:enable Metrics/ClassLength