From 861f4b0caf309c68be096eb26256f74e0e743bb1 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:06 -0400 Subject: [PATCH 1/7] Update ruby-bandwidth-iris Gem It looks like the commits on this branch were merged to upstream a long time ago, and other changes have been made since which I'm going to need in a moment. --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index c9623ae3dc918289b6f1b8c1e9a17d8e8b830fbf..481e938ab6f846b195eac2cc5b6a137ea6e86358 100644 --- a/Gemfile +++ b/Gemfile @@ -20,7 +20,7 @@ gem "multihashes" gem "ougai" gem "relative_time" gem "roda" -gem "ruby-bandwidth-iris", git: "https://github.com/singpolyma/ruby-bandwidth-iris", branch: "tn_move" +gem "ruby-bandwidth-iris" gem "sentry-ruby", "<= 4.3.1" gem "slim" gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite" From 4866cdf737e7a91ad376326a58d877548e03f126 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:07 -0400 Subject: [PATCH 2/7] Move to New Blather Version with Better IDs This is currently just my branch, but we'll move to another commit after it gets merged. This commit makes it so session IDs and IQ IDs are unique rather than the current state where they're always blather01, blather02, etc. Given that we just start, run roughly the same commands, and then close it meant all of our sessions were predictably numbered, and so we were seeing some previous runs of the script picking up old sessions, etc. Not great! Also, at some point between whenever the last version was and now the API changed for handlers, so we have to do that now. If we don't, the commands just sit there and never respond. Bad. So this makes our extensions more inline with upstream's new API. --- Gemfile | 2 +- lib/blather_client.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 481e938ab6f846b195eac2cc5b6a137ea6e86358..1434dcd91915aa18af9fb265380c573ce9fe7ea1 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" gem "amazing_print" gem "bandwidth-sdk", "<= 6.1.0" -gem "blather", git: "https://github.com/adhearsion/blather", branch: "develop" +gem "blather", git: "https://github.com/psycotica0/blather", branch: "cv_new_id" gem "braintree" gem "dhall", ">= 0.5.3.fixed" gem "em-hiredis" diff --git a/lib/blather_client.rb b/lib/blather_client.rb index 6de5c2ddf899d9b9999f5ade86db6c335011eecc..29aec145b9e0a82b4644f6f6167a4d2e18efa1a8 100644 --- a/lib/blather_client.rb +++ b/lib/blather_client.rb @@ -59,7 +59,8 @@ class BlatherClient < Blather::Client found = stanza.find(*guards) handler.call(stanza, found) unless found.empty? else - handler.call(stanza) unless guarded?(guards, stanza) + throw :pass if guarded?(guards, stanza) + handler.call(stanza) end return result unless result.is_a?(Promise) From 8bac167d5249df54c50cf0f91b3dcfc7b2cb37db Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:08 -0400 Subject: [PATCH 3/7] Allow User FWD to be Nil The data-model supports it, so we probably shouldn't crash --- forms/admin_info.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forms/admin_info.rb b/forms/admin_info.rb index 9b31c1ed3c9d684481d5f02b0e5d9c1f7657d774..81b44dfb9dfaaa6490d97b48babd3d8f8d09df01 100644 --- a/forms/admin_info.rb +++ b/forms/admin_info.rb @@ -31,7 +31,7 @@ if @admin_info.info.tel ) end -if @admin_info.fwd.uri +if @admin_info.fwd&.uri field( var: "fwd", label: "Fwd", From 19661cb2cbe33e870574074b55d06a098f713c54 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:09 -0400 Subject: [PATCH 4/7] Import Blather Notify and ToForm from JMP Pay These are some helpers I'm going to use to run my porting logic later. I've imported BlatherNotify verbatim here, though I will be making changes in later commits. ToForm on the other hand has been just added to the existing Form refinement we already had, making it a two-way conversion now. --- lib/blather_notify.rb | 138 ++++++++++++++++++++++++++++++++++++++++++ lib/form_to_h.rb | 12 ++++ 2 files changed, 150 insertions(+) create mode 100644 lib/blather_notify.rb diff --git a/lib/blather_notify.rb b/lib/blather_notify.rb new file mode 100644 index 0000000000000000000000000000000000000000..6c5716cd3655cd28e94184818cd0c611efbe7e83 --- /dev/null +++ b/lib/blather_notify.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "blather/client/dsl" +require "em_promise" +require "timeout" + +module BlatherNotify + extend Blather::DSL + + class PubSub + class Address + attr_reader :node, :server + + def initialize(node:, server:) + @node = node + @server = server + end + + def to_uri + "xmpp:#{@server}?;node=#{@node}" + end + end + + def initialize(blather, addr) + @blather = blather + @addr = addr + end + + def publish(xml) + @blather.write_with_promise( + Blather::Stanza::PubSub::Publish.new( + @addr.server, + @addr.node, + :set, + xml + ) + ) + end + end + + @ready = Queue.new + + when_ready { @ready << :ready } + + def self.start(jid, password, default_pubsub_addr: nil) + # workqueue_count MUST be 0 or else Blather uses threads! + setup(jid, password, nil, nil, nil, nil, workqueue_count: 0) + set_default_pubsub(default_pubsub_addr) + + EM.error_handler(&method(:panic)) + + EM.next_tick { client.run } + + block_until_ready + end + + def self.block_until_ready + if EM.reactor_running? + promise = EMPromise.new + disconnected { true.tap { EM.next_tick { EM.stop } } } + Thread.new { promise.fulfill(@ready.pop) } + timeout_promise(promise, timeout: 30) + else + @thread = Thread.new { EM.run } + Timeout.timeout(30) { @ready.pop } + at_exit { wait_then_exit } + end + end + + def self.panic(e) + warn e.message + warn e.backtrace + exit! 2 + end + + def self.wait_then_exit + disconnected { EM.stop } + EM.add_timer(30) { EM.stop } + shutdown + @thread&.join + end + + def self.timeout_promise(promise, timeout: 15) + timer = EventMachine::Timer.new(timeout) { + promise.reject(:timeout) + } + + promise.then do + timer.cancel + end + end + + def self.write_with_promise(stanza) + promise = EMPromise.new + timeout_promise(promise) + + client.write_with_handler(stanza) do |s| + if s.error? || s.type == :error + promise.reject(s) + else + promise.fulfill(s) + end + end + promise + end + + def self.command(node, sessionid=nil, action: :execute, form: nil) + Blather::Stanza::Iq::Command.new.tap do |cmd| + cmd.to = CONFIG[:sgx_jmp] + cmd.node = node + cmd.command[:sessionid] = sessionid if sessionid + cmd.action = action + cmd.command << form if form + end + end + + def self.execute(command_node, form=nil) + write_with_promise(command(command_node)).then do |iq| + next iq unless form + + write_with_promise(command(command_node, iq.sessionid, form: form)) + end + end + + def self.pubsub(addr) + PubSub.new(self, addr) + end + + def self.set_default_pubsub(addr) + @default_pubsub = addr && pubsub(addr) + end + + def self.publish(xml) + raise "No default pubsub set!" unless @default_pubsub + + @default_pubsub.publish(xml) + end +end diff --git a/lib/form_to_h.rb b/lib/form_to_h.rb index 3ab2fed5d4e46b3070f2b382058bb0883b1f985d..69705b942e0dfc7a95a6842fa9e0fe49196e1e5a 100644 --- a/lib/form_to_h.rb +++ b/lib/form_to_h.rb @@ -18,4 +18,16 @@ module FormToH params.to_h end end + + refine ::Hash do + def to_fields + map { |k, v| { var: k.to_s, value: v.to_s } } + end + + def to_form(type) + Blather::Stanza::Iq::X.new(type).tap do |form| + form.fields = to_fields + end + end + end end From 74983b2032d93650f544bb128af3cd03e09e086d Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:10 -0400 Subject: [PATCH 5/7] Add CommandExecution to BlatherNotify This follows a single command through a chain of steps and takes care of the flow where sometimes I fetch a form before I submit it, and other times I've already gotten the form on the last step, etc. --- lib/blather_notify.rb | 74 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/lib/blather_notify.rb b/lib/blather_notify.rb index 6c5716cd3655cd28e94184818cd0c611efbe7e83..70d2300c44e30e58c20ae704178c3300907ce433 100644 --- a/lib/blather_notify.rb +++ b/lib/blather_notify.rb @@ -4,6 +4,10 @@ require "blather/client/dsl" require "em_promise" require "timeout" +require "ostruct" +require "securerandom" +require_relative "form_to_h" + module BlatherNotify extend Blather::DSL @@ -38,6 +42,65 @@ module BlatherNotify end end + class CommandExecution + using FormToH + + class FormErrorResponse < RuntimeError; end + + def initialize(blather, server, node) + @blather = blather + @server = server + @node = node + @sessionid = nil + end + + def fetch_and_submit(**form) + get_form.then { |response| + @sessionid ||= response.sessionid + + validate_form(form, response.form) + + @blather.write_with_promise(@blather.command( + @node, @sessionid, form: form.to_form(:submit), server: @server + )) + }.then(&method(:check_for_error)).then { |response| + @last_response = response + OpenStruct.new(response.form.to_h) + } + end + + protected + + def check_for_error(response) + if response.note&.[]("type") == "error" + raise FormErrorResponse, response.note.text + end + + response + end + + def validate_form(to_submit, received) + to_submit.each_key do |key| + raise "No field #{key}" unless received.field(key.to_s) + end + end + + def get_form + # If we already got a form on the last submit then + # assume we should fill that out here. + # If not, then move next to find the form + if @last_response&.form&.form? + EMPromise.resolve(@last_response) + else + @blather.write_with_promise(@blather.command( + @node, + @sessionid, + server: @server + )) + end + end + end + @ready = Queue.new when_ready { @ready << :ready } @@ -104,9 +167,12 @@ module BlatherNotify promise end - def self.command(node, sessionid=nil, action: :execute, form: nil) + def self.command( + node, sessionid=nil, + server: CONFIG[:sgx_jmp], action: :execute, form: nil + ) Blather::Stanza::Iq::Command.new.tap do |cmd| - cmd.to = CONFIG[:sgx_jmp] + cmd.to = server cmd.node = node cmd.command[:sessionid] = sessionid if sessionid cmd.action = action @@ -135,4 +201,8 @@ module BlatherNotify @default_pubsub.publish(xml) end + + def self.command_execution(server, node) + CommandExecution.new(self, server, node) + end end From 53115475d89aaa444e95231c949384f1a1b134e9 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:11 -0400 Subject: [PATCH 6/7] Porting Logic Ok, so I've built a big tree of states here to represent us trying to figure out what the current situation is in this mad world. Then each one of those leaves has some thing that it does to hopefully move towards the next step. There's also the warnings which are emitted, while also still attempting to make progress. We want to tell people about things, but not get stuck in "busted" mode forever. --- lib/porting_step.rb | 311 ++++++++++++++++++++++++++++ test/test_porting_step.rb | 412 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 723 insertions(+) create mode 100644 lib/porting_step.rb create mode 100644 test/test_porting_step.rb diff --git a/lib/porting_step.rb b/lib/porting_step.rb new file mode 100644 index 0000000000000000000000000000000000000000..3d3e93779f1f68ff83c3e5f31d9bcbdf1e7419f7 --- /dev/null +++ b/lib/porting_step.rb @@ -0,0 +1,311 @@ +# frozen_string_literal: true + +require "date" +require "em_promise" +require "lazy_object" +require "value_semantics/monkey_patched" + +require_relative "blather_notify" + +class Tel + def initialize(str) + @tel = if str.is_a? Tel + str.to_s + else + "+1#{str.sub(/^\+?1?/, '')}" + end + end + + def to_s + @tel + end + + def ==(other) + to_s == other.to_s + end +end + +class PortingStepRepo + # Any thing that debounces messages must happen inside this. + # The porting logic will pass everything in every time. + class Outputs + def info(port_id, kind, msg); end + + def warn(port_id, kind, msg); end + + def error(port_id, kind, e_or_msg); end + + def to_customer(port_id, kind, tel, msg); end + end + + value_semantics do + redis Anything(), default: LazyObject.new { REDIS } + blather_notify Anything(), default: BlatherNotify + admin_server Anything(), default: CONFIG[:admin_server] + testing_tel Anything(), default: CONFIG[:testing_tel] + output Outputs, default: Outputs.new + end + + def find(port) + if_processing(port) do + case port.processing_status + when "FOC" + FOC.new(**to_h).find(port) + when "COMPLETE" + Complete.new(**to_h).find(port) + else + Wait.new(port, output: output) + end + end + end + + def if_processing(port) + redis.exists("jmp_port_freeze-#{port.order_id}").then do |v| + next Frozen.new(port, output: output) if v == 1 + + yield + end + end + + class FOC < self + # This is how long we'll wait for a port to move from FOC to COMPLETED + # It's in fractional days because DateTime + GRACE_PERIOD = 15.0 / (24 * 60) + + def find(port) + Alert.for( + port, + grace_period: GRACE_PERIOD, + output: output, key: :late_foc, + msg: "⚠ Port is still in FOC state a while past FOC", + real_step: Wait.new(port, output: output) + ) + end + end + + class Complete < self + # If it's been 35 minutes and the number isn't reachable, a human + # should get involved + GRACE_PERIOD = 35.0 / (24 * 60) + + def find(port) + if_not_complete(port) do + exe = blather_notify.command_execution(admin_server, "customer info") + AdminCommand.new(exe: exe, **to_h).find(port).then do |step| + Alert.for( + port, + grace_period: GRACE_PERIOD, output: output, + key: :late_finish, msg: msg(port), real_step: step + ) + end + end + end + + def if_not_complete(port) + redis.exists("jmp_port_complete-#{port.order_id}").then do |v| + next Done.new(port, output: output) if v == 1 + + yield + end + end + + def msg(port) + "⚠ Port still hasn't finished. We'll keep trying unless you set redis "\ + "key `jmp_port_freeze-#{port.order_id}`" + end + + class AdminCommand < self + def initialize(exe:, **kwargs) + @exe = exe + super(**kwargs) + end + + def to_h + super.merge(exe: @exe) + end + + def find(port) + @exe.fetch_and_submit(q: port.customer_order_id).then do |form| + tel = Tel.new(port.billing_telephone_number) + if tel == Tel.new(form.tel) + GoodNumber.new(**to_h).find(port) + else + WrongNumber.new(right_number: tel, execution: @exe) + end + end + end + + class GoodNumber < self + def find(port) + Reachability.new(type: "voice", **to_h).test(port) do + Reachability.new(type: "sms", **to_h).test(port) do + FinishUp.new(port, redis: redis, output: output) + end + end + end + + class Reachability < self + def initialize(type:, **args) + @type = type + super(**args) + end + + def test(port) + execute_command(port).then do |response| + if response.count == "0" + RunTest.new( + type: @type, tel: port.billing_telephone_number, + **to_h.slice(:blather_notify, :testing_tel, :admin_server) + ) + else + yield + end + end + end + + class RunTest + # This is here for tests + attr_reader :type + + def initialize( + type:, tel:, blather_notify:, testing_tel:, admin_server: + ) + @type = type + @tel = tel + @blather_notify = blather_notify + @testing_tel = testing_tel + @admin_server = admin_server + end + + def perform_next_step + @blather_notify.command_execution(@admin_server, "reachability") + .fetch_and_submit( + tel: @tel, type: @type, reachability_tel: @testing_tel + ) + end + end + + protected + + def execute_command(port) + blather_notify.command_execution(admin_server, "reachability") + .fetch_and_submit(tel: port.billing_telephone_number, type: @type) + end + end + + class FinishUp + MESSAGE = "Hi! This is JMP support - your number has "\ + "successfully transferred in to JMP! All calls/messages "\ + "will now use your transferred-in number - your old JMP "\ + "number has been disabled. Let us know if you have any "\ + "questions and thanks for using JMP!" + + def initialize(port, redis:, output:) + @port = port + @redis = redis + @output = output + end + + def set_key + @redis.set( + "jmp_port_complete-#{@port.order_id}", + DateTime.now.iso8601, + "EX", + 60 * 60 * 24 * 2 ### 2 Days should be enough to not see it listed + ) + end + + def perform_next_step + set_key.then do + EMPromise.all([ + @output.info(@port.order_id, :complete, "Port Complete!"), + @output.to_customer( + @port.order_id, :complete, + Tel.new(@port.billing_telephone_number), MESSAGE + ) + ]) + end + end + end + end + + class WrongNumber + def initialize(right_number:, execution:) + @right_number = right_number + @exe = execution + end + + def perform_next_step + @exe.fetch_and_submit(action: "number_change").then do |_form| + @exe.fetch_and_submit(new_tel: @right_number, should_delete: "true") + end + end + end + end + end + + # This doesn't do anything and just waits for something to happen later + class Wait + def initialize(port, output:) + @port = port + @output = output + end + + def perform_next_step + @output.info(@port.order_id, :wait, "Waiting...") + end + end + + # This also doesn't do anything but is more content about it + class Done + def initialize(port, output:) + @port = port + @output = output + end + + def perform_next_step + @output.info(@port.order_id, :done, "Done.") + end + end + + # This also also doesn't do anything but is intentional + class Frozen + def initialize(port, output:) + @port = port + @output = output + end + + def perform_next_step + @output.info(@port.order_id, :frozen, "Frozen.") + end + end + + # This class sends and error to the human to check things out + class Alert + def self.for(port, grace_period:, real_step:, **args) + if (DateTime.now - port.actual_foc_date) > grace_period + new(port, real_step: real_step, **args) + else + real_step + end + end + + # For tests + attr_reader :key + attr_reader :real_step + + def initialize(port, real_step:, output:, msg:, key:) + @port = port + @real_step = real_step + @output = output + @msg = msg + @key = key + end + + def perform_next_step + @output.warn(@port.order_id, @key, @msg).then { + @real_step.perform_next_step + } + end + end +end diff --git a/test/test_porting_step.rb b/test/test_porting_step.rb new file mode 100644 index 0000000000000000000000000000000000000000..940110d94b6a41d1be2d370703bc1c27d1c14afe --- /dev/null +++ b/test/test_porting_step.rb @@ -0,0 +1,412 @@ +# frozen_string_literal: true + +require "date" +require "ostruct" +require "test_helper" + +require "customer_info" +require "form_template" +require "form_to_h" +require "porting_step" + +MINS = 1.0 / (24 * 60) + +class BlatherNotifyMock < Minitest::Mock + def initialize + super + @exes = [] + end + + def expect_execution(server, node, *args) + exe = Execution.new(args) + expect( + :command_execution, + exe, + [server, node] + ) + @exes << exe + end + + def verify + super + @exes.each(&:verify) + end + + class Execution < Minitest::Mock + def initialize(args) + super() + args.each_slice(2) do |(submission, result)| + expect( + :fetch_and_submit, + EMPromise.resolve(to_response(result)), + **submission + ) + end + end + + using FormToH + + def to_response(form) + OpenStruct.new(form.to_h) + end + end +end + +def info(tel) + CustomerInfo.new( + plan_info: PlanInfo::NoPlan.new, + tel: tel, + balance: 0.to_d, + cnam: nil + ) +end + +def admin_info(customer_id, tel) + AdminInfo.new( + jid: Blather::JID.new("#{customer_id}@example.com"), + customer_id: customer_id, + fwd: nil, + info: info(tel), + api: API::V2.new, + call_info: "", + trust_level: "", + backend_jid: "customer_#{customer_id}@example.com" + ) +end + +def menu + FormTemplate.render("admin_menu") +end + +class PortingStepTest < Minitest::Test + Port = Struct.new( + :order_id, + :processing_status, + :actual_foc_date, + :last_modified_date, + :customer_order_id, + :billing_telephone_number + ) + + def test_ignore_submitted_ports + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + + step = PortingStepRepo.new(redis: redis).find(Port.new( + "01", + "SUBMITTED", + nil, + DateTime.now - 1 * MINS, + "ignored", + "9998887777" + )).sync + + assert_kind_of PortingStepRepo::Wait, step + end + em :test_ignore_submitted_ports + + def test_ignore_recent_foc + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + + step = PortingStepRepo.new(redis: redis).find(Port.new( + "01", + "FOC", + DateTime.now - 5 * MINS, + DateTime.now - 1 * MINS, + "ignored", + "9998887777" + )).sync + + assert_kind_of PortingStepRepo::Wait, step + end + em :test_ignore_recent_foc + + def test_warn_for_late_foc + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + + step = PortingStepRepo.new(redis: redis).find(Port.new( + "01", + "FOC", + DateTime.now - 25 * MINS, + DateTime.now - 1 * MINS, + "ignored", + "9998887777" + )).sync + + assert_kind_of PortingStepRepo::Alert, step + assert_equal :late_foc, step.key + assert_kind_of PortingStepRepo::Wait, step.real_step + end + em :test_warn_for_late_foc + + def test_already_complete + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + redis.expect(:exists, 1, ["jmp_port_complete-01"]) + + step = PortingStepRepo.new(redis: redis).find(Port.new( + "01", + "COMPLETE", + DateTime.now - 25 * MINS, + DateTime.now - 1 * MINS, + "completed", + "9998887777" + )).sync + + assert_kind_of PortingStepRepo::Done, step + assert_mock redis + end + em :test_already_complete + + def test_change_number + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + redis.expect(:exists, "0", ["jmp_port_complete-01"]) + + notify = BlatherNotifyMock.new + notify.expect_execution( + "sgx", "customer info", + { q: "starting" }, admin_info("starting", "+19998881111").form + ) + + step = PortingStepRepo.new( + redis: redis, + blather_notify: notify, + admin_server: "sgx" + ).find(Port.new( + "01", + "COMPLETE", + DateTime.now - 25 * MINS, + DateTime.now - 1 * MINS, + "starting", + "9998887777" + )).sync + + assert_kind_of PortingStepRepo::Complete::AdminCommand::WrongNumber, step + assert_mock redis + assert_mock notify + end + em :test_change_number + + def test_first_reachability + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + redis.expect(:exists, "0", ["jmp_port_complete-01"]) + + notify = BlatherNotifyMock.new + notify.expect_execution( + "sgx", "customer info", + { q: "starting" }, admin_info("starting", "+19998887777").form + ) + + notify.expect_execution( + "sgx", "reachability", + { tel: "9998887777", type: "voice" }, + FormTemplate.render("reachability_result", count: 0) + ) + + step = PortingStepRepo.new( + redis: redis, + blather_notify: notify, + admin_server: "sgx" + ).find(Port.new( + "01", + "COMPLETE", + DateTime.now - 25 * MINS, + DateTime.now - 1 * MINS, + "starting", + "9998887777" + )).sync + + assert_kind_of( + PortingStepRepo::Complete::AdminCommand::GoodNumber:: + Reachability::RunTest, + step + ) + assert_equal "voice", step.type + assert_mock redis + assert_mock notify + end + em :test_first_reachability + + def test_reach_sms_reachability + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + redis.expect(:exists, "0", ["jmp_port_complete-01"]) + + notify = BlatherNotifyMock.new + notify.expect_execution( + "sgx", "customer info", + { q: "starting" }, admin_info("starting", "+19998887777").form + ) + + notify.expect_execution( + "sgx", "reachability", + { tel: "9998887777", type: "voice" }, + FormTemplate.render("reachability_result", count: 1) + ) + + notify.expect_execution( + "sgx", "reachability", + { tel: "9998887777", type: "sms" }, + FormTemplate.render("reachability_result", count: 0) + ) + + step = PortingStepRepo.new( + redis: redis, + blather_notify: notify, + admin_server: "sgx" + ).find(Port.new( + "01", + "COMPLETE", + DateTime.now - 25 * MINS, + DateTime.now - 1 * MINS, + "starting", + "9998887777" + )).sync + + assert_kind_of( + PortingStepRepo::Complete::AdminCommand::GoodNumber:: + Reachability::RunTest, + step + ) + assert_equal "sms", step.type + assert_mock redis + assert_mock notify + end + em :test_reach_sms_reachability + + def test_all_reachable + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + redis.expect(:exists, "0", ["jmp_port_complete-01"]) + + notify = BlatherNotifyMock.new + notify.expect_execution( + "sgx", "customer info", + { q: "starting" }, admin_info("starting", "+19998887777").form + ) + + notify.expect_execution( + "sgx", "reachability", + { tel: "9998887777", type: "voice" }, + FormTemplate.render("reachability_result", count: 1) + ) + + notify.expect_execution( + "sgx", "reachability", + { tel: "9998887777", type: "sms" }, + FormTemplate.render("reachability_result", count: 1) + ) + + step = PortingStepRepo.new( + redis: redis, + blather_notify: notify, + admin_server: "sgx" + ).find(Port.new( + "01", + "COMPLETE", + DateTime.now - 25 * MINS, + DateTime.now - 1 * MINS, + "starting", + "9998887777" + )).sync + + assert_kind_of( + PortingStepRepo::Complete::AdminCommand::GoodNumber::FinishUp, + step + ) + assert_mock redis + assert_mock notify + end + em :test_all_reachable + + def test_not_done_in_time + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(0), ["jmp_port_freeze-01"]) + redis.expect(:exists, "0", ["jmp_port_complete-01"]) + + notify = BlatherNotifyMock.new + notify.expect_execution( + "sgx", "customer info", + { q: "starting" }, admin_info("starting", "+19998887777").form + ) + + notify.expect_execution( + "sgx", "reachability", + { tel: "9998887777", type: "voice" }, + FormTemplate.render("reachability_result", count: 0) + ) + + step = PortingStepRepo.new( + redis: redis, + blather_notify: notify, + admin_server: "sgx" + ).find(Port.new( + "01", + "COMPLETE", + DateTime.now - 55 * MINS, + DateTime.now - 50 * MINS, + "starting", + "9998887777" + )).sync + + assert_kind_of PortingStepRepo::Alert, step + assert_equal :late_finish, step.key + assert_kind_of( + PortingStepRepo::Complete::AdminCommand::GoodNumber:: + Reachability::RunTest, + step.real_step + ) + assert_mock redis + assert_mock notify + end + em :test_not_done_in_time + + def test_ignore_frozen_ports + # This tests that we ignore ports in various states + [ + Port.new( + "01", + "SUBMITTED", + nil, + DateTime.now - 1 * MINS, + "ignored", + "9998887777" + ), + Port.new( + "01", + "FOC", + DateTime.now - 300 * MINS, + DateTime.now - 300 * MINS, + "ignored", + "9998887777" + ), + Port.new( + "01", + "COMPLETED", + DateTime.now - 10 * MINS, + DateTime.now - 10 * MINS, + "ignored", + "9998887777" + ), + Port.new( + "01", + "COMPLETED", + DateTime.now - 300 * MINS, + DateTime.now - 300 * MINS, + "ignored", + "9998887777" + ) + ].each do |port| + redis = Minitest::Mock.new + redis.expect(:exists, EMPromise.resolve(1), ["jmp_port_freeze-01"]) + + step = PortingStepRepo.new(redis: redis).find(port).sync + assert_kind_of PortingStepRepo::Frozen, step + end + end + em :test_ignore_frozen_ports +end From 052cbfa263e4ad09b2a0cd0c99c3a78649ee2181 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Fri, 17 Mar 2023 16:34:12 -0400 Subject: [PATCH 7/7] Porting Script This does a few things. It's meant to be run headlessly, but it can also be run by a human if they want to do it manually. So in pursuit of that it has a few options. The defaults ignore informational logs, send exceptions to sentry, warnings to a pubsub channel, and send messages to the customer to tell them the port is finished. But the manual mode logs informational messages, warnings, and errors all to the terminal. And then it can either also log the things it would have said to the customer, or it can send those automatically still. I've also got an option for making fake ports. Yeah, it's just used when testing the thing, but given that I run it that way 90% of the time, it felt weird to just have it floating around in my working dir and having to keep remembering to take it out, commit, put it back, etc. So I just decided I'd put it in here behind a flag. --- bin/porting | 270 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100755 bin/porting diff --git a/bin/porting b/bin/porting new file mode 100755 index 0000000000000000000000000000000000000000..8313e6b881caeb8cd8ce5ab011e01e51dd54d7e9 --- /dev/null +++ b/bin/porting @@ -0,0 +1,270 @@ +#!/usr/bin/ruby +# frozen_string_literal: true + +require "date" +require "dhall" +require "em-hiredis" +require "em-http" +require "em_promise" +require "json" +require "optparse" +require "ruby-bandwidth-iris" +require "securerandom" +require "sentry-ruby" +require "time" + +@verbosity = 0 +@real_data = true +@dry_run = false + +OptionParser.new do |opts| + opts.banner = "Usage: porting [-vvf] DHALL_CONFIG" + + opts.on( + "-v", "--verbose", + "Print to terminal, run twice to not even send to customer" + ) do + @verbosity += 1 + end + + opts.on("-f", "--fake", "Run with fake ports rather than fetching") do + @real_data = false + end + + opts.on( + "-n", "--dry-run", + "Figure out what state they're in, but don't take action" + ) do + @dry_run = true + end + + opts.on("-h", "--help", "Print this help") do + puts opts + exit + end +end.parse! + +SCHEMA = "{ + bandwidth : { account: Text, username: Text, password: Text }, + xmpp: { jid: Text, password: Text }, + notification: { endpoint: Text, source_number: Text }, + pubsub: { server: Text, node: Text }, + testing_tel: Text, + admin_server: Text +}" + +raise "Need a Dhall config" unless ARGV[0] + +CONFIG = Dhall::Coder + .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc]) + .load("#{ARGV.first} : #{SCHEMA}", transform_keys: :to_sym) + +require_relative "../lib/blather_notify" +require_relative "../lib/expiring_lock" +require_relative "../lib/form_to_h" +require_relative "../lib/porting_step" + +Faraday.default_adapter = :em_synchrony +BandwidthIris::Client.global_options = { + account_id: CONFIG[:bandwidth][:account], + username: CONFIG[:bandwidth][:username], + password: CONFIG[:bandwidth][:password] +} + +class FullManual < PortingStepRepo::Outputs + def info(id, key, msg) + puts "[#{id}] INFO(#{key}): #{msg}" + end + + def warn(id, key, msg) + puts "[#{id}] WARN(#{key}): #{msg}" + end + + def error(id, key, e_or_msg) + puts "[#{id}] ERRR(#{key}): #{e_or_msg}" + return unless e_or_msg.respond_to?(:backtrace) + + e_or_msg.backtrace.each do |b| + puts "[#{id}] ERRR(#{key}): #{b}" + end + end + + def to_customer(id, key, tel, msg) + puts "[#{id}] CUST(#{key}, #{tel}): #{msg}" + end +end + +class ObservedAuto < FullManual + def initialize(endpoint, source_number) + @endpoint = endpoint + @src = source_number + end + + def to_customer(id, key, tel, msg) + ExpiringLock.new(lock_key(id, key)).with do + EM::HttpRequest + .new(@endpoint) + .apost( + head: { "Content-Type" => "application/json" }, + body: format_msg(tel, msg) + ) + end + end + +protected + + def lock_key(id, key) + "jmp_port_customer_msg_#{key}-#{id}" + end + + def format_msg(tel, msg) + [{ + time: DateTime.now.iso8601, + type: "message-received", + to: tel, + description: "Incoming message received", + message: actual_message(tel, msg) + }].to_json + end + + def actual_message(tel, msg) + { + id: SecureRandom.uuid, + owner: tel, + applicationId: SecureRandom.uuid, + time: DateTime.now.iso8601, + segmentCount: 1, + direction: "in", + to: [tel], from: @src, + text: msg + } + end +end + +class FullAutomatic < ObservedAuto + using FormToH + + def initialize(pubsub_addr, endpoint, source_number) + @pubsub = BlatherNotify.pubsub(pubsub_addr) + + Sentry.init do |config| + config.background_worker_threads = 0 + end + + super(endpoint, source_number) + end + + # No one's watch; swallow informational messages + def info(*); end + + def warn(id, key, msg) + ExpiringLock.new(warn_lock_key(id, key), expiry: 60 * 15).with do + entrykey = "#{id}:#{key}" + @pubsub.publish("#{entrykey}": error_entry("Port Warning", msg, entrykey)) + end + end + + def error(id, key, e_or_msg) + Sentry.with_scope do |scope| + scope.set_context("port", { id: id, action: key }) + + if e_or_msg.is_a?(::Exception) + Sentry.capture_exception(e_or_msg) + else + Sentry.capture_message(e_or_msg.to_s) + end + end + end + +protected + + def error_entry(title, text, id) + Nokogiri::XML::Builder.new { |xml| + xml.entry(xmlns: "http://www.w3.org/2005/Atom") do + xml.updated DateTime.now.iso8601 + xml.id id + xml.title title + xml.content text.to_s, type: "text" + xml.author { xml.name "porting" } + xml.generator "porting", version: "1.0" + end + }.doc.root + end + + def warn_lock_key(id, key) + "jmp_port_warn_msg_#{key}-#{id}" + end +end + +@output = + case @verbosity + when 0 + FullAutomatic.new( + BlatherNotify::PubSub::Address.new(**CONFIG[:pubsub]), + CONFIG[:notification][:endpoint], + CONFIG[:notification][:source_number] + ) + when 1 + ObservedAuto.new( + CONFIG[:notification][:endpoint], + CONFIG[:notification][:source_number] + ) + else + FullManual.new + end + +ports = if @real_data + BandwidthIris::PortIn.list( + page: 1, + size: 50, + start_date: Date.today - 1, + end_date: Date.today + ) || [] +else + MP = Struct.new( + :order_id, + :processing_status, + :actual_foc_date, + :last_modified_date, + :customer_order_id, + :billing_telephone_number + ) + + minutes = 1.0 / (24 * 60) + + [ + # This should be ignored + MP.new("T01", "SUBMITTED", nil, DateTime.now - 1, "ignored", "9998887777"), + MP.new( + "T02", "COMPLETE", DateTime.now - 60 * minutes, + DateTime.now - 55 * minutes, "0001", "2223334444" + ) + ] +end + +EM.run do + REDIS = EM::Hiredis.connect + + BlatherNotify.start( + CONFIG[:xmpp][:jid], + CONFIG[:xmpp][:password] + ).then { + ports.reduce(EMPromise.resolve(nil)) { |promise, port| + promise.then do + @output.info(port.order_id, :start, "Here we go") + PortingStepRepo.new(output: @output).find(port).then { |s| + @output.info(port.order_id, :class, s.class) + s + }.then { |s| + if @dry_run + @output.info("DRY", :dry, "Not taking action") + else + s.perform_next_step + end + } + end + } + }.catch { |e| + @output.error("ROOT", :catch, e) + }.then { BlatherNotify.shutdown } +end