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
M Rakefile => Rakefile +1 -1
@@ 17,7 17,7 @@ end
RuboCop::RakeTask.new(:lint)
task :entr do
- sh "sh", "-c", "git ls-files | entr -s 'rubocop && rake test'"
+ sh "sh", "-c", "git ls-files | entr -s 'rake test && rubocop'"
end
task default: :test
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/registration.rb => lib/registration.rb +12 -10
@@ 9,15 9,15 @@ require_relative "./command"
require_relative "./bandwidth_tn_order"
require_relative "./em"
require_relative "./oob"
-require_relative "./web_register_manager"
+require_relative "./tel_selections"
class Registration
- def self.for(customer, web_register_manager)
+ def self.for(customer, tel_selections)
customer.registered?.then do |registered|
if registered
Registered.new(registered.phone)
else
- web_register_manager[customer.jid].then(&:choose_tel).then do |tel|
+ tel_selections[customer.jid].then(&:choose_tel).then do |tel|
Activation.for(customer, tel)
end
end
@@ 425,18 425,20 @@ class Registration
def write
BandwidthTNOrder.create(@tel).then(&:poll).then(
->(_) { customer_active_tel_purchased },
- lambda do |_|
- Command.finish(
- "The JMP number #{@tel} is no longer available, " \
- "please visit https://jmp.chat and choose another.",
- type: :error
- )
- end
+ ->(_) { number_purchase_error }
)
end
protected
+ def number_purchase_error
+ TEL_SELECTIONS.delete(@customer.jid).then {
+ TelSelections::ChooseTel.new.choose_tel(
+ error: "The JMP number #{@tel} is no longer available."
+ )
+ }.then { |tel| Finish.new(@customer, tel).write }
+ end
+
def cheogram_sip_addr
"sip:#{ERB::Util.url_encode(@customer.jid)}@sip.cheogram.com"
end
A lib/tel_selections.rb => lib/tel_selections.rb +198 -0
@@ 0,0 1,198 @@
+# 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
+
+ def initialize(redis: REDIS)
+ @redis = redis
+ end
+
+ def set(jid, tel)
+ @redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel)
+ end
+
+ def delete(jid)
+ @redis.del("pending_tel_for-#{jid}")
+ end
+
+ def [](jid)
+ @redis.get("pending_tel_for-#{jid}").then do |tel|
+ tel ? HaveTel.new(tel) : ChooseTel.new
+ end
+ end
+
+ class HaveTel
+ def initialize(tel)
+ @tel = tel
+ end
+
+ def choose_tel
+ EMPromise.resolve(@tel)
+ end
+ end
+
+ class ChooseTel
+ 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 formatted_tel
+ @tel =~ /\A\+1(\d{3})(\d{3})(\d+)\Z/
+ "(#{$1}) #{$2}-#{$3}"
+ end
+
+ def option
+ op = Blather::Stanza::X::Field::Option.new(value: tel, label: to_s)
+ op << reference
+ op
+ end
+
+ def reference
+ Nokogiri::XML::Builder.new { |xml|
+ xml.reference(
+ xmlns: "urn:xmpp:reference:0",
+ begin: 0,
+ end: formatted_tel.length - 1,
+ type: "data",
+ uri: "tel:#{tel}"
+ )
+ }.doc.root
+ end
+
+ def to_s
+ "#{formatted_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
D lib/web_register_manager.rb => lib/web_register_manager.rb +0 -39
@@ 1,39 0,0 @@
-# frozen_string_literal: true
-
-class WebRegisterManager
- THIRTY_DAYS = 60 * 60 * 24 * 30
-
- def initialize(redis: REDIS)
- @redis = redis
- end
-
- def set(jid, tel)
- @redis.setex("pending_tel_for-#{jid}", THIRTY_DAYS, tel)
- end
-
- def [](jid)
- @redis.get("pending_tel_for-#{jid}").then do |tel|
- tel ? HaveTel.new(tel) : ChooseTel.new
- end
- end
-
- class HaveTel
- def initialize(tel)
- @tel = tel
- end
-
- def choose_tel
- EMPromise.resolve(@tel)
- end
- 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
- )
- end
- end
-end
M sgx_jmp.rb => sgx_jmp.rb +4 -4
@@ 77,7 77,7 @@ require_relative "lib/low_balance"
require_relative "lib/payment_methods"
require_relative "lib/registration"
require_relative "lib/transaction"
-require_relative "lib/web_register_manager"
+require_relative "lib/tel_selections"
require_relative "lib/session_manager"
require_relative "lib/statsd"
@@ 169,7 169,7 @@ when_ready do
LOG.info "Ready"
BLATHER = self
REDIS = EM::Hiredis.connect
- WEB_REGISTER_MANAGER = WebRegisterManager.new
+ TEL_SELECTIONS = TelSelections.new
BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
DB = PG::EM::ConnectionPool.new(dbname: "jmp") do |conn|
conn.type_map_for_results = PG::BasicTypeMapForResults.new(conn)
@@ 392,7 392,7 @@ Command.new(
Command.execution.customer_repo.create(Command.execution.iq.from.stripped)
}.then { |customer|
Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Registration.for"))
- Registration.for(customer, WEB_REGISTER_MANAGER).then(&:write)
+ Registration.for(customer, TEL_SELECTIONS).then(&:write)
}.then {
StatsD.increment("registration.completed")
}.catch_only(Command::Execution::FinalStanza) do |e|
@@ 547,7 547,7 @@ command :execute?, node: "web-register", sessionid: nil do |iq|
cmd.form.fields = [var: "to", value: jid]
cmd.form.type = "submit"
}).then { |result|
- WEB_REGISTER_MANAGER.set(result.form.field("from")&.value.to_s.strip, tel)
+ TEL_SELECTIONS.set(result.form.field("from")&.value.to_s.strip, tel)
}.then {
BLATHER << iq.reply.tap { |reply| reply.status = :completed }
}.catch { |e| panic(e, sentry_hub) }
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_helper.rb => test/test_helper.rb +22 -0
@@ 34,6 34,7 @@ rescue LoadError
end
require "backend_sgx"
+require "tel_selections"
$VERBOSE = nil
Sentry.init
@@ 132,6 133,27 @@ class PromiseMock < Minitest::Mock
end
end
+class FakeTelSelections
+ def initialize
+ @selections = {}
+ end
+
+ def set(jid, tel)
+ @selections[jid] = EMPromise.resolve(TelSelections::HaveTel.new(tel))
+ end
+
+ def delete(jid)
+ @selections.delete(jid)
+ EMPromise.resolve("OK")
+ end
+
+ def [](jid)
+ @selections.fetch(jid) do
+ TelSelections::ChooseTel.new
+ end
+ end
+end
+
class FakeRedis
def initialize(values={})
@values = values
M test/test_registration.rb => test/test_registration.rb +22 -14
@@ 35,7 35,7 @@ class RegistrationTest < Minitest::Test
em :test_for_registered
def test_for_activated
- web_manager = WebRegisterManager.new(redis: FakeRedis.new)
+ web_manager = TelSelections.new(redis: FakeRedis.new)
web_manager.set("test@example.net", "+15555550000")
result = execute_command do
sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))
@@ 54,7 54,7 @@ class RegistrationTest < Minitest::Test
def test_for_not_activated_with_customer_id
sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))
- web_manager = WebRegisterManager.new(redis: FakeRedis.new)
+ web_manager = TelSelections.new(redis: FakeRedis.new)
web_manager.set("test@example.net", "+15555550000")
iq = Blather::Stanza::Iq::Command.new
iq.from = "test@example.com"
@@ 514,6 514,8 @@ class RegistrationTest < Minitest::Test
end
class FinishTest < Minitest::Test
+ Command::COMMAND_MANAGER = Minitest::Mock.new
+ Registration::Finish::TEL_SELECTIONS = FakeTelSelections.new
Registration::Finish::REDIS = Minitest::Mock.new
BackendSgx::REDIS = Minitest::Mock.new
@@ 628,23 630,29 @@ class RegistrationTest < Minitest::Test
<OrderStatus>FAILED</OrderStatus>
</OrderResponse>
RESPONSE
- blather = Minitest::Mock.new
- blather.expect(
- :<<,
- nil,
- [Matching.new do |reply|
- assert_equal :completed, reply.status
- assert_equal :error, reply.note_type
+
+ Command::COMMAND_MANAGER.expect(
+ :write,
+ EMPromise.reject(:test_result),
+ [Matching.new do |iq|
+ assert_equal :form, iq.form.type
assert_equal(
- "The JMP number +15555550000 is no longer available, " \
- "please visit https://jmp.chat and choose another.",
- reply.note.content
+ "The JMP number +15555550000 is no longer available.",
+ iq.form.instructions
)
end]
)
- execute_command(blather: blather) { @finish.write }
+
+ assert_equal(
+ :test_result,
+ execute_command { @finish.write.catch { |e| e } }
+ )
+ assert_mock Command::COMMAND_MANAGER
+ assert_instance_of(
+ TelSelections::ChooseTel,
+ Registration::Finish::TEL_SELECTIONS["test@example.com"]
+ )
assert_requested create_order
- assert_mock blather
end
em :test_write_tn_fail
end
A test/test_tel_selections.rb => test/test_tel_selections.rb +131 -0
@@ 0,0 1,131 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "tel_selections"
+
+class TelSelectionsTest < Minitest::Test
+ def setup
+ @manager = TelSelections.new(redis: FakeRedis.new)
+ end
+
+ def test_set_get
+ assert_kind_of TelSelections::ChooseTel, @manager["jid@example.com"].sync
+ @manager.set("jid@example.com", "+15555550000").sync
+ assert_kind_of TelSelections::HaveTel, @manager["jid@example.com"].sync
+ end
+ em :test_set_get
+
+ def test_choose_tel_have_tel
+ jid = "jid@example.com"
+ @manager.set(jid, "+15555550000").sync
+ 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 "(555) 123-4567 (Toronto, ON)", @tn.to_s
+ end
+
+ def test_tel
+ assert_equal "+15551234567", @tn.tel
+ end
+
+ def test_option
+ assert_equal(
+ Blather::Stanza::X::Field::Option.new(
+ label: "(555) 123-4567 (Toronto, ON)",
+ value: "+15551234567"
+ ),
+ @tn.option
+ )
+ end
+
+ def test_option_reference
+ ref = @tn.option.find("ns:reference", ns: "urn:xmpp:reference:0").first
+ assert_equal(
+ @tn.formatted_tel,
+ @tn.option.label[ref["begin"].to_i..ref["end"].to_i]
+ )
+ assert_equal "tel:+15551234567", ref["uri"]
+ 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
D test/test_web_register_manager.rb => test/test_web_register_manager.rb +0 -24
@@ 1,24 0,0 @@
-# frozen_string_literal: true
-
-require "test_helper"
-require "web_register_manager"
-
-class WebRegisterManagerTest < Minitest::Test
- def setup
- @manager = WebRegisterManager.new(redis: FakeRedis.new)
- end
-
- def test_set_get
- assert_kind_of WebRegisterManager::ChooseTel, @manager["jid@example.com"].sync
- @manager.set("jid@example.com", "+15555550000").sync
- assert_kind_of WebRegisterManager::HaveTel, @manager["jid@example.com"].sync
- end
- em :test_set_get
-
- def test_choose_tel_have_tel
- jid = "jid@example.com"
- @manager.set(jid, "+15555550000").sync
- assert_equal "+15555550000", @manager[jid].then(&:choose_tel).sync
- end
- em :test_choose_tel_have_tel
-end