porting_step_repo.rb

  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