Allow user to search for numbers over XMPP

Stephen Paul Weber created

Change summary

.rubocop.yml                |   7 +
forms/tn_list.rb            |  14 +++
forms/tn_search.rb          |  15 +++
lib/form_template.rb        |  64 ++++++++++++++++
lib/tel_selections.rb       | 148 +++++++++++++++++++++++++++++++++++++-
test/test_form_template.rb  |  65 +++++++++++++++++
test/test_tel_selections.rb |  95 +++++++++++++++++++++++++
7 files changed, 402 insertions(+), 6 deletions(-)

Detailed changes

.rubocop.yml 🔗

@@ -54,6 +54,9 @@ Style/DoubleNegation:
 Style/PerlBackrefs:
   Enabled: false
 
+Style/SpecialGlobalVars:
+  EnforcedStyle: use_perl_names
+
 Style/RegexpLiteral:
   EnforcedStyle: slashes
   AllowInnerSlashes: true
@@ -82,5 +85,9 @@ Style/FormatString:
 Style/FormatStringToken:
   EnforcedStyle: unannotated
 
+Style/FrozenStringLiteralComment:
+  Exclude:
+    - forms/*
+
 Naming/AccessorMethodName:
   Enabled: false

forms/tn_list.rb 🔗

@@ -0,0 +1,14 @@
+form!
+title "Choose Telephone Number"
+instructions "Please choose one of the following numbers"
+field(
+	var: "tel",
+	required: true,
+	type: "list-single",
+	label: "Telephone Number",
+	options: @tns.map(&:option)
+)
+
+xml.set(xmlns: "http://jabber.org/protocol/rsm") do |xml|
+	xml.count @tns.length.to_s
+end

forms/tn_search.rb 🔗

@@ -0,0 +1,15 @@
+form!
+title "Search Telephone Numbers"
+instructions @error if @error
+field(
+	var: "q",
+	required: true,
+	type: "text-single",
+	label: "Search Telephone Numbers",
+	description:
+		"Enter one of: Area code; six or seven digit " \
+		"number prefix; zip code; city, state/province; " \
+		"or indicate a vanity pattern with ~"
+)
+
+xml.set(xmlns: "http://jabber.org/protocol/rsm")

lib/form_template.rb 🔗

@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+require "blather"
+
+class FormTemplate
+	def initialize(template, filename="template", **kwargs)
+		@args = kwargs
+		@template = template
+		@filename = filename
+		freeze
+	end
+
+	def self.render(path, **kwargs)
+		full_path = File.dirname(__dir__) + "/forms/#{path}.rb"
+		new(File.read(full_path), full_path, **kwargs).render
+	end
+
+	def render(**kwargs)
+		one = OneRender.new(**@args.merge(kwargs))
+		one.instance_eval(@template, @filename)
+		one.form
+	end
+
+	class OneRender
+		def initialize(**kwargs)
+			kwargs.each do |k, v|
+				instance_variable_set("@#{k}", v)
+			end
+			@__form = Blather::Stanza::X.new
+			@__builder = Nokogiri::XML::Builder.with(@__form)
+		end
+
+		def form!
+			@__type_set = true
+			@__form.type = :form
+		end
+
+		def result!
+			@__type_set = true
+			@__form.type = :result
+		end
+
+		def title(s)
+			@__form.title = s
+		end
+
+		def instructions(s)
+			@__form.instructions = s
+		end
+
+		def field(**kwargs)
+			@__form.fields = @__form.fields + [kwargs]
+		end
+
+		def xml
+			@__builder
+		end
+
+		def form
+			raise "Type never set" unless @__type_set
+			@__form
+		end
+	end
+end

lib/tel_selections.rb 🔗

@@ -1,5 +1,10 @@
 # frozen_string_literal: true
 
+require "ruby-bandwidth-iris"
+Faraday.default_adapter = :em_synchrony
+
+require_relative "form_template"
+
 class TelSelections
 	THIRTY_DAYS = 60 * 60 * 24 * 30
 
@@ -28,12 +33,143 @@ class TelSelections
 	end
 
 	class ChooseTel
-		def choose_tel
-			Command.finish(
-				"You have not chosen a phone number yet, please return to " \
-				"https://jmp.chat and choose one now.",
-				type: :error
-			)
+		def choose_tel(error: nil)
+			Command.reply { |reply|
+				reply.allowed_actions = [:next]
+				reply.command << FormTemplate.render("tn_search", error: error)
+			}.then { |iq| choose_from_list(AvailableNumber.for(iq.form).tns) }
+		end
+
+		def choose_from_list(tns)
+			if tns.empty?
+				choose_tel(error: "No numbers found, try another search.")
+			else
+				Command.reply { |reply|
+					reply.allowed_actions = [:next]
+					reply.command << FormTemplate.render("tn_list", tns: tns)
+				}.then { |iq| iq.form.field("tel").value.to_s.strip }
+			end
+		end
+
+		class AvailableNumber
+			def self.for(form)
+				new(
+					Q
+					.for(form.field("q").value.to_s.strip).iris_query
+					.merge(enableTNDetail: true)
+					.merge(Quantity.for(form).iris_query)
+				)
+			end
+
+			def initialize(iris_query)
+				@iris_query = iris_query
+			end
+
+			def tns
+				Command.log.debug("BandwidthIris::AvailableNumber.list", @iris_query)
+				BandwidthIris::AvailableNumber.list(@iris_query).map(&Tn.method(:new))
+			end
+
+			class Quantity
+				def self.for(form)
+					rsm_max = form.find(
+						"ns:set/ns:max",
+						ns: "http://jabber.org/protocol/rsm"
+					).first
+					if rsm_max
+						new(rsm_max.content.to_i)
+					else
+						Default.new
+					end
+				end
+
+				def initialize(quantity)
+					@quantity = quantity
+				end
+
+				def iris_query
+					{ quantity: @quantity }
+				end
+
+				# NOTE: Gajim sends back the whole list on submit, so big
+				# lists can cause issues
+				class Default
+					def iris_query
+						{ quantity: 10 }
+					end
+				end
+			end
+		end
+
+		class Tn
+			attr_reader :tel
+
+			def initialize(full_number:, city:, state:, **)
+				@tel = "+1#{full_number}"
+				@locality = city
+				@region = state
+			end
+
+			def option
+				{ value: tel, label: to_s }
+			end
+
+			def to_s
+				"#{@tel} (#{@locality}, #{@region})"
+			end
+		end
+
+		class Q
+			def self.register(regex, &block)
+				@queries ||= []
+				@queries << [regex, block]
+			end
+
+			def self.for(q)
+				@queries.each do |(regex, block)|
+					match_data = (q =~ regex)
+					return block.call($1 || $&, *$~.to_a[2..-1]) if match_data
+				end
+
+				raise "Format not recognized: #{q}"
+			end
+
+			def initialize(q)
+				@q = q
+			end
+
+			{
+				areaCode: [:AreaCode, /\A[2-9][0-9]{2}\Z/],
+				npaNxx: [:NpaNxx, /\A(?:[2-9][0-9]{2}){2}\Z/],
+				npaNxxx: [:NpaNxxx, /\A(?:[2-9][0-9]{2}){2}[0-9]\Z/],
+				zip: [:PostalCode, /\A\d{5}(?:-\d{4})?\Z/],
+				localVanity: [:LocalVanity, /\A~(.+)\Z/]
+			}.each do |k, args|
+				klass = const_set(
+					args[0],
+					Class.new(Q) do
+						define_method(:iris_query) do
+							{ k => @q }
+						end
+					end
+				)
+
+				args[1..-1].each do |regex|
+					register(regex) { |q| klass.new(q) }
+				end
+			end
+
+			class CityState
+				Q.register(/\A([^,]+)\s*,\s*([A-Z]{2})\Z/, &method(:new))
+				def initialize(city, state)
+					@city = city
+					@state = state
+				end
+
+				def iris_query
+					{ city: @city, state: @state }
+				end
+			end
 		end
 	end
 end

test/test_form_template.rb 🔗

@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "form_template"
+
+class FormTemplateTest < Minitest::Test
+	def test_form_one_field
+		template = FormTemplate.new(<<~TEMPLATE)
+			form!
+			title "TITLE"
+			instructions "INSTRUCTIONS"
+			field(var: "thevar", label: "thelabel")
+		TEMPLATE
+		form = template.render
+		assert_equal :form, form.type
+		assert_equal "TITLE", form.title
+		assert_equal "INSTRUCTIONS", form.instructions
+		assert_equal 1, form.fields.length
+		assert_equal "thevar", form.fields[0].var
+		assert_equal "thelabel", form.fields[0].label
+	end
+
+	def test_form_two_fields
+		template = FormTemplate.new(<<~TEMPLATE)
+			form!
+			field(var: "thevar", label: "thelabel")
+			field(var: "thevar2", label: "thelabel2")
+		TEMPLATE
+		form = template.render
+		assert_equal 2, form.fields.length
+		assert_equal "thevar", form.fields[0].var
+		assert_equal "thelabel", form.fields[0].label
+		assert_equal "thevar2", form.fields[1].var
+		assert_equal "thelabel2", form.fields[1].label
+	end
+
+	def test_result_no_fields
+		template = FormTemplate.new(<<~TEMPLATE)
+			result!
+			title "TITLE"
+			instructions "INSTRUCTIONS"
+		TEMPLATE
+		form = template.render
+		assert_equal :result, form.type
+		assert_equal "TITLE", form.title
+		assert_equal "INSTRUCTIONS", form.instructions
+	end
+
+	def test_no_type
+		template = FormTemplate.new(<<~TEMPLATE)
+			title "TITLE"
+			instructions "INSTRUCTIONS"
+		TEMPLATE
+		assert_raises { template.render }
+	end
+
+	def test_custom_xml
+		template = FormTemplate.new(<<~TEMPLATE)
+			form!
+			xml.whoever @arg
+		TEMPLATE
+		form = template.render(arg: "abc")
+		assert_equal "abc", form.at("whoever").content
+	end
+end

test/test_tel_selections.rb 🔗

@@ -21,4 +21,99 @@ class TelSelectionsTest < Minitest::Test
 		assert_equal "+15555550000", @manager[jid].then(&:choose_tel).sync
 	end
 	em :test_choose_tel_have_tel
+
+	class AvailableNumberTest < Minitest::Test
+		def test_for_no_rsm
+			form = Blather::Stanza::X.new
+			form.fields = [{ var: "q", value: "226" }]
+			iris_query =
+				TelSelections::ChooseTel::AvailableNumber
+				.for(form)
+				.instance_variable_get(:@iris_query)
+			assert_equal(
+				{ areaCode: "226", enableTNDetail: true, quantity: 10 },
+				iris_query
+			)
+		end
+
+		def test_for_rsm
+			form = Blather::Stanza::X.new
+			form.fields = [{ var: "q", value: "226" }]
+			Nokogiri::XML::Builder.with(form) do
+				set(xmlns: "http://jabber.org/protocol/rsm") do
+					max 500
+				end
+			end
+			iris_query =
+				TelSelections::ChooseTel::AvailableNumber
+				.for(form)
+				.instance_variable_get(:@iris_query)
+			assert_equal(
+				{ areaCode: "226", enableTNDetail: true, quantity: 500 },
+				iris_query
+			)
+		end
+	end
+
+	class TnTest < Minitest::Test
+		def setup
+			@tn = TelSelections::ChooseTel::Tn.new(
+				full_number: "5551234567",
+				city: "Toronto",
+				state: "ON",
+				garbage: "stuff"
+			)
+		end
+
+		def test_to_s
+			assert_equal "+15551234567 (Toronto, ON)", @tn.to_s
+		end
+
+		def test_tel
+			assert_equal "+15551234567", @tn.tel
+		end
+
+		def test_option
+			assert_equal(
+				{ label: "+15551234567 (Toronto, ON)", value: "+15551234567" },
+				@tn.option
+			)
+		end
+	end
+
+	class QTest < Minitest::Test
+		def test_for_area_code
+			q = TelSelections::ChooseTel::Q.for("226")
+			assert_equal({ areaCode: "226" }, q.iris_query)
+		end
+
+		def test_for_npanxx
+			q = TelSelections::ChooseTel::Q.for("226666")
+			assert_equal({ npaNxx: "226666" }, q.iris_query)
+		end
+
+		def test_for_npanxxx
+			q = TelSelections::ChooseTel::Q.for("2266667")
+			assert_equal({ npaNxxx: "2266667" }, q.iris_query)
+		end
+
+		def test_for_zip
+			q = TelSelections::ChooseTel::Q.for("90210")
+			assert_equal({ zip: "90210" }, q.iris_query)
+		end
+
+		def test_for_localvanity
+			q = TelSelections::ChooseTel::Q.for("~mboa")
+			assert_equal({ localVanity: "mboa" }, q.iris_query)
+		end
+
+		def test_for_citystate
+			q = TelSelections::ChooseTel::Q.for("Toronto, ON")
+			assert_equal({ city: "Toronto", state: "ON" }, q.iris_query)
+		end
+
+		def test_for_garbage
+			assert_raises { TelSelections::ChooseTel::Q.for("garbage") }
+		end
+	end
 end