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