Reachability Command

Christopher Vollick created

This creates the keys that the previous commits use, and also reports
their current values.

It's a pretty sparse command in terms of UX, but only admins will use it
so I kept it simple.

This also optionally sends the prompt to one of the chosen reachability
numbers. It's optional so a human running the command can query the
result without looking in redis, but also without spamming their phone
with another message.

Change summary

forms/reachability.rb        | 28 ++++++++++++
forms/reachability_result.rb |  8 +++
lib/backend_sgx.rb           |  2 
lib/reachability_form.rb     | 84 +++++++++++++++++++++++++++++++++++++
lib/reachability_repo.rb     |  9 ++++
sgx_jmp.rb                   | 30 +++++++++++++
6 files changed, 160 insertions(+), 1 deletion(-)

Detailed changes

forms/reachability.rb 🔗

@@ -0,0 +1,28 @@
+form!
+title "Reachability"
+
+field(
+	var: "tel",
+	datatype: "html:tel",
+	required: true,
+	label: "Number to test"
+)
+
+field(
+	var: "reachability_tel",
+	type: "list-single",
+	options: CONFIG[:reachability_senders]
+		.map { |tel| { label: tel, value: tel } },
+	label: "Number to send test prompt to",
+	desc: "leave blank to not send a prompt"
+)
+
+field(
+	var: "type",
+	type: "list-single",
+	options: [
+		{ label: "SMS", value: "sms" },
+		{ label: "Voice", value: "voice" }
+	],
+	label: "Type of test to perform"
+)

forms/reachability_result.rb 🔗

@@ -0,0 +1,8 @@
+result!
+title "Reachability Result"
+
+field(
+	var: "count",
+	value: @count,
+	label: "Received requests so far"
+)

lib/backend_sgx.rb 🔗

@@ -40,7 +40,7 @@ class BackendSgx
 				domain: jid.domain,
 				node: jid.node || stanza.to.node
 			)
-			stanza.from = from_jid.with(resource: stanza.from.resource)
+			stanza.from = from_jid.with(resource: stanza.from&.resource)
 		end
 	end
 

lib/reachability_form.rb 🔗

@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require "value_semantics/monkey_patched"
+
+require_relative "customer"
+require_relative "form_template"
+require_relative "form_to_h"
+require_relative "reachability_repo"
+
+class ReachabilityForm
+	using FormToH
+
+	REPOS = {
+		"sms" => ReachabilityRepo::SMS,
+		"voice" => ReachabilityRepo::Voice
+	}.freeze
+
+	class Result
+		value_semantics do
+			repo ReachabilityRepo
+			target Customer
+			sender Either(String, nil)
+		end
+
+		def prompt
+			return unless sender
+
+			Blather::Stanza::Message.new.tap do |m|
+				m.body = "/#{repo.type}"
+				# stanza_from will fix domain
+				m.to = Blather::JID.new("#{sender}@sgx-jmp")
+			end
+		end
+	end
+
+	def initialize(customer_repo)
+		@customer_repo = customer_repo
+	end
+
+	def render
+		FormTemplate.render("reachability")
+	end
+
+	def render_result(count)
+		FormTemplate.render("reachability_result", count: count)
+	end
+
+	def parse(form)
+		params = form.to_h
+		tel = cleanup_tel(params["tel"])
+
+		sender = cleanup_sender(params["reachability_tel"])
+
+		repo = REPOS[params["type"]]
+		raise "Type is invalid" unless repo
+
+		find_target(tel).then { |target|
+			Result.new(target: target, repo: repo.new, sender: sender)
+		}
+	end
+
+protected
+
+	def find_target(tel)
+		@customer_repo.find_by_tel(tel)
+	end
+
+	def cleanup_sender(str)
+		return nil unless str
+
+		str = str.strip
+		return nil if str.empty?
+
+		unless CONFIG[:reachability_senders].include?(str)
+			raise "Sender not in whitelist"
+		end
+
+		str
+	end
+
+	def cleanup_tel(str)
+		"+1#{str.gsub(/\A\+?1?/, '')}"
+	end
+end

lib/reachability_repo.rb 🔗

@@ -8,6 +8,8 @@ class ReachabilityRepo
 		senders ArrayOf(String), default: CONFIG[:reachability_senders]
 	end
 
+	EXPIRY = 60 * 60 # 1 hr
+
 	class SMS < self
 		def type
 			"sms"
@@ -47,6 +49,13 @@ class ReachabilityRepo
 		end
 	end
 
+	# This creates the keys if they don't exist, and returns the value
+	def get_or_create(customer)
+		redis.set(key(customer), 0, "NX", "EX", EXPIRY).then {
+			redis.get(key(customer))
+		}
+	end
+
 protected
 
 	# This is basically an optimization to bail early without hitting a

sgx_jmp.rb 🔗

@@ -96,6 +96,7 @@ require_relative "lib/patches_for_sentry"
 require_relative "lib/payment_methods"
 require_relative "lib/paypal_done"
 require_relative "lib/postgres"
+require_relative "lib/reachability_form"
 require_relative "lib/reachability_repo"
 require_relative "lib/registration"
 require_relative "lib/transaction"
@@ -824,6 +825,35 @@ Command.new(
 	end
 }.register(self).then(&CommandList.method(:register))
 
+Command.new(
+	"reachability",
+	"Test Reachability",
+	list_for: ->(customer: nil, **) { customer&.admin? }
+) {
+	Command.customer.then do |customer|
+		raise AuthError, "You are not an admin" unless customer&.admin?
+
+		form = ReachabilityForm.new(CustomerRepo.new)
+
+		Command.reply { |reply|
+			reply.allowed_actions = [:next]
+			reply.command << form.render
+		}.then { |response|
+			form.parse(response.form)
+		}.then { |result|
+			result.repo.get_or_create(result.target).then { |v|
+				result.target.stanza_from(result.prompt) if result.prompt
+
+				Command.finish { |reply|
+					reply.command << form.render_result(v)
+				}
+			}
+		}.catch_only(RuntimeError) { |e|
+			Command.finish(e, type: :error)
+		}
+	end
+}.register(self).then(&CommandList.method(:register))
+
 Command.new(
 	"snikket",
 	"Launch Snikket Instance",