~singpolyma/sgx-jmp

5d2a8e6daab8a06f14a5554e85c8a2a90d994006 — Stephen Paul Weber 8 months ago 5a1e3cb
Allow user to search for numbers over XMPP
M .rubocop.yml => .rubocop.yml +7 -0
@@ 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

A forms/tn_list.rb => forms/tn_list.rb +14 -0
@@ 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

A forms/tn_search.rb => forms/tn_search.rb +15 -0
@@ 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")

A lib/form_template.rb => lib/form_template.rb +64 -0
@@ 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

M lib/tel_selections.rb => lib/tel_selections.rb +142 -6
@@ 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

A test/test_form_template.rb => test/test_form_template.rb +65 -0
@@ 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

M test/test_tel_selections.rb => test/test_tel_selections.rb +95 -0
@@ 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