1# frozen_string_literal: true
2
3require "date"
4require "value_semantics/monkey_patched"
5require "em_promise"
6
7require_relative "blather_notify"
8require_relative "utils"
9
10class PortingStepRepo
11 # Any thing that debounces messages must happen inside this.
12 # The porting logic will pass everything in every time.
13 class Outputs
14 def info(port_id, kind, msg); end
15
16 def warn(port_id, kind, msg); end
17
18 def error(port_id, kind, e_or_msg); end
19
20 def to_customer(port_id, kind, tel, msg); end
21 end
22
23 value_semantics do
24 redis Anything(), default: LazyObject.new { REDIS }
25 blather_notify Anything(), default: BlatherNotify
26 admin_server Anything(), default: CONFIG[:admin_server]
27 testing_tel Anything(), default: CONFIG[:testing_tel]
28 output Outputs, default: Outputs.new
29 end
30
31 def find(port)
32 if_processing(port) do
33 case port.processing_status
34 when "FOC"
35 FOC.new(**to_h).find(port)
36 when "COMPLETE"
37 Complete.new(**to_h).find(port)
38 else
39 Wait.new(port, output: output)
40 end
41 end
42 end
43
44 def if_processing(port)
45 redis.exists("jmp_port_freeze-#{port.id}").then do |v|
46 next Frozen.new(port, output: output) if v == 1
47
48 yield
49 end
50 end
51
52 class FOC < self
53 # This is how long we'll wait for a port to move from FOC to COMPLETED
54 # It's in fractional days because DateTime
55 GRACE_PERIOD = 15.0 / (24 * 60)
56
57 def find(port)
58 Alert.for(
59 port,
60 grace_period: GRACE_PERIOD,
61 output: output, key: :late_foc,
62 msg: "⚠ Port is still in FOC state a while past FOC",
63 real_step: Wait.new(port, output: output)
64 )
65 end
66 end
67
68 class Complete < self
69 # If it's been 35 minutes and the number isn't reachable, a human
70 # should get involved
71 GRACE_PERIOD = 35.0 / (24 * 60)
72
73 def find(port)
74 if_not_complete(port) do
75 exe = blather_notify.command_execution(admin_server, "customer info")
76 AdminCommand.new(exe: exe, **to_h).find(port).then do |step|
77 Alert.for(
78 port,
79 grace_period: GRACE_PERIOD, output: output,
80 key: :late_finish, msg: msg(port), real_step: step
81 )
82 end
83 end
84 end
85
86 def if_not_complete(port)
87 redis.exists("jmp_port_complete-#{port.id}").then do |v|
88 next Done.new(port, output: output) if v == 1
89
90 yield
91 end
92 end
93
94 def msg(port)
95 "⚠ Port still hasn't finished. We'll keep trying unless you set redis " \
96 "key `jmp_port_freeze-#{port.id}`"
97 end
98
99 class NoCustomer < self
100 attr_reader :port
101
102 NO_GRACE_PERIOD = 0
103
104 def initialize(port:, **kwargs)
105 @port = port
106 super(**kwargs)
107 end
108
109 def self.for(port:, **kwargs)
110 Alert.for(
111 port,
112 grace_period: NO_GRACE_PERIOD,
113 real_step: new(port: port, **kwargs),
114 output: kwargs[:output],
115 msg: msg(port),
116 key: :port_for_unknown_customer
117 )
118 end
119
120 def self.msg(port)
121 "⚠ Freezing port #{port.id} for unknown customer: #{port.customer_id}."
122 end
123
124 def perform_next_step
125 redis.set("jmp_port_freeze-#{port.id}", 1)
126 end
127 end
128
129 class AdminCommand < self
130 def initialize(exe:, **kwargs)
131 @exe = exe
132 super(**kwargs)
133 end
134
135 def to_h
136 super.merge(exe: @exe)
137 end
138
139 def find(port)
140 h = to_h
141 @exe.fetch_and_submit(q: port.customer_id).then do |form|
142 next NoCustomer.for(port: port, **h.except(:exe)) unless form.tel
143
144 if port.tel == form.tel && form.route == port.backend_sgx.to_s
145 GoodNumber.new(**h).find(port)
146 else
147 WrongNumber.new(port: port, execution: @exe)
148 end
149 end
150 end
151
152 class GoodNumber < self
153 def find(port)
154 Reachability.new(type: "voice", **to_h).test(port) do
155 Reachability.new(type: "sms", **to_h).test(port) do
156 FinishUp.new(port, redis: redis, output: output)
157 end
158 end
159 rescue StandardError => e
160 ReachabilityFailure.for(error: e, port: port, **to_h)
161 end
162
163 class ReachabilityFailure < self
164 attr_reader :error
165
166 NO_GRACE_PERIOD = 0
167
168 # @param error [StandardError]
169 def self.for(port:, error:, **kwargs)
170 Alert.for(
171 port,
172 grace_period: NO_GRACE_PERIOD,
173 real_step: nil,
174 output: kwargs[:output],
175 msg: msg(port, error),
176 key: :reachability_failure
177 )
178 end
179
180 def self.msg(port, error)
181 "⚠ Error checking #{port.id} reachability: #{error}"
182 end
183 end
184
185 class Reachability < self
186 def initialize(type:, **args)
187 @type = type
188 super(**args)
189 end
190
191 def test(port)
192 execute_command(port).then { |response|
193 next yield unless response.count == "0"
194
195 args = to_h.slice(
196 :blather_notify, :testing_tel, :admin_server, :output
197 )
198 RunTest.new(type: @type, tel: port.tel, port: port, **args)
199 }.catch do |e|
200 ReachabilityFailure.for(port: port, error: e, output: output)
201 end
202 end
203
204 class RunTest
205 # This is here for tests
206 attr_reader :type
207
208 def initialize(
209 type:, tel:, port:, output:,
210 blather_notify:, testing_tel:, admin_server:
211 )
212 @type = type
213 @tel = tel
214 @port = port
215 @output = output
216 @blather_notify = blather_notify
217 @testing_tel = testing_tel
218 @admin_server = admin_server
219 end
220
221 def perform_next_step
222 @blather_notify.command_execution(@admin_server, "reachability")
223 .fetch_and_submit(
224 tel: @tel, type: @type, reachability_tel: @testing_tel
225 ).catch do |e|
226 ReachabilityFailure.for(
227 port: @port, error: e, output: @output
228 ).perform_next_step
229 end
230 end
231 end
232
233 protected
234
235 def execute_command(port)
236 blather_notify.command_execution(admin_server, "reachability")
237 .fetch_and_submit(tel: port.tel, type: @type)
238 end
239 end
240
241 class FinishUp
242 MESSAGE = "Hi! This is JMP support - your number has " \
243 "successfully transferred in to JMP! All calls/messages " \
244 "will now use your transferred-in number - your old JMP " \
245 "number has been disabled. Let us know if you have any " \
246 "questions and thanks for using JMP!"
247
248 def initialize(port, redis:, output:)
249 @port = port
250 @redis = redis
251 @output = output
252 end
253
254 def set_key
255 @redis.set(
256 "jmp_port_complete-#{@port.id}",
257 DateTime.now.iso8601,
258 "EX",
259 60 * 60 * 24 * 2 ### 2 Days should be enough to not see it listed
260 )
261 end
262
263 def perform_next_step
264 set_key.then do
265 EMPromise.all([
266 @output.info(@port.id, :complete, "Port Complete!"),
267 @output.to_customer(
268 @port.id, :complete,
269 Tel.new(@port.tel), MESSAGE
270 )
271 ])
272 end
273 end
274 end
275 end
276
277 class WrongNumber
278 attr_reader :new_backend
279
280 def initialize(port:, execution:)
281 @new_backend = port.backend_sgx
282 @right_number = port.tel
283 @exe = execution
284 end
285
286 def perform_next_step
287 @exe.fetch_and_submit(action: "number_change").then do |_form|
288 @exe.fetch_and_submit(
289 new_backend: @new_backend,
290 new_tel: @right_number,
291 should_delete: "true"
292 )
293 end
294 end
295 end
296 end
297 end
298
299 # This doesn't do anything and just waits for something to happen later
300 class Wait
301 def initialize(port, output:)
302 @port = port
303 @output = output
304 end
305
306 def perform_next_step
307 @output.info(@port.id, :wait, "Waiting...")
308 end
309 end
310
311 # This also doesn't do anything but is more content about it
312 class Done
313 def initialize(port, output:)
314 @port = port
315 @output = output
316 end
317
318 def perform_next_step
319 @output.info(@port.id, :done, "Done.")
320 end
321 end
322
323 # This also also doesn't do anything but is intentional
324 class Frozen
325 def initialize(port, output:)
326 @port = port
327 @output = output
328 end
329
330 def perform_next_step
331 @output.info(@port.id, :frozen, "Frozen.")
332 end
333 end
334
335 # This class sends and error to the human to check things out
336 class Alert
337 # @param [PortingStepRepo, NilClass] real_step If `nil`, just notify
338 def self.for(port, grace_period:, real_step:, **args)
339 if (DateTime.now - port.actual_foc_date.to_datetime) > grace_period
340 new(port, real_step: real_step, **args)
341 else
342 real_step
343 end
344 end
345
346 # For tests
347 attr_reader :key
348 attr_reader :real_step
349
350 def initialize(port, real_step:, output:, msg:, key:)
351 @port = port
352 @real_step = real_step
353 @output = output
354 @msg = msg
355 @key = key
356 end
357
358 def perform_next_step
359 @output.warn(@port.id, @key, @msg).then {
360 @real_step&.perform_next_step
361 }
362 end
363 end
364end
365
366class FullManual < PortingStepRepo::Outputs
367 def info(id, key, msg)
368 puts "[#{id}] INFO(#{key}): #{msg}"
369 end
370
371 def warn(id, key, msg)
372 puts "[#{id}] WARN(#{key}): #{msg}"
373 end
374
375 def error(id, key, e_or_msg)
376 puts "[#{id}] ERRR(#{key}): #{e_or_msg}"
377 return unless e_or_msg.respond_to?(:backtrace)
378
379 e_or_msg.backtrace.each do |b|
380 puts "[#{id}] ERRR(#{key}): #{b}"
381 end
382 end
383
384 def to_customer(id, key, tel, msg)
385 puts "[#{id}] CUST(#{key}, #{tel}): #{msg}"
386 end
387end
388
389class ObservedAuto < FullManual
390 def initialize(endpoint, source_number)
391 @endpoint = endpoint
392 @src = source_number
393 end
394
395 def to_customer(id, key, tel, msg)
396 ExpiringLock.new(lock_key(id, key)).with do
397 EM::HttpRequest
398 .new(@endpoint)
399 .apost(
400 head: { "Content-Type" => "application/json" },
401 body: format_msg(tel, msg)
402 )
403 end
404 end
405
406protected
407
408 def lock_key(id, key)
409 "jmp_port_customer_msg_#{key}-#{id}"
410 end
411
412 def format_msg(tel, msg)
413 [{
414 time: DateTime.now.iso8601,
415 type: "message-received",
416 to: tel,
417 description: "Incoming message received",
418 message: actual_message(tel, msg)
419 }].to_json
420 end
421
422 def actual_message(tel, msg)
423 {
424 id: SecureRandom.uuid,
425 owner: tel,
426 applicationId: SecureRandom.uuid,
427 time: DateTime.now.iso8601,
428 segmentCount: 1,
429 direction: "in",
430 to: [tel], from: @src,
431 text: msg
432 }
433 end
434end
435
436class FullAutomatic < ObservedAuto
437 using FormToH
438
439 def initialize(pubsub_addr, endpoint, source_number)
440 @pubsub = BlatherNotify.pubsub(pubsub_addr)
441
442 Sentry.init do |config|
443 config.background_worker_threads = 0
444 end
445
446 super(endpoint, source_number)
447 end
448
449 # No one's watch; swallow informational messages
450 def info(*); end
451
452 def warn(id, key, msg)
453 ExpiringLock.new(warn_lock_key(id, key), expiry: 60 * 15).with do
454 entrykey = "#{id}:#{key}"
455 @pubsub.publish("#{entrykey}": error_entry("Port Warning", msg, entrykey))
456 end
457 end
458
459 def error(id, key, e_or_msg)
460 Sentry.with_scope do |scope|
461 scope.set_context("port", { id: id, action: key })
462
463 if e_or_msg.is_a?(::Exception)
464 Sentry.capture_exception(e_or_msg)
465 else
466 Sentry.capture_message(e_or_msg.to_s)
467 end
468 end
469 end
470
471protected
472
473 def error_entry(title, text, id)
474 Nokogiri::XML::Builder.new { |xml|
475 xml.entry(xmlns: "http://www.w3.org/2005/Atom") do
476 xml.updated DateTime.now.iso8601
477 xml.id id
478 xml.title title
479 xml.content text.to_s, type: "text"
480 xml.author { xml.name "porting" }
481 xml.generator "porting", version: "1.0"
482 end
483 }.doc.root
484 end
485
486 def warn_lock_key(id, key)
487 "jmp_port_warn_msg_#{key}-#{id}"
488 end
489end