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