Detailed changes
@@ -1,4 +1,4 @@
-Metrics/LineLength:
+Layout/LineLength:
Max: 80
Layout/IndentationStyle:
@@ -9,7 +9,7 @@ Layout/IndentationStyle:
Layout/IndentationWidth:
Width: 1 # one tab
-Lint/EndAlignment:
+Layout/EndAlignment:
EnforcedStyleAlignWith: variable
Lint/RescueException:
@@ -7,7 +7,11 @@ gem 'blather', git: "https://github.com/adhearsion/blather.git", branch: "develo
gem 'em-hiredis'
gem 'em-http-request'
gem 'em_promise.rb'
+gem 'em-synchrony'
gem 'eventmachine'
+gem 'faraday', '~> 1.0'
+gem 'faraday-em_synchrony'
+gem 'ruby-bandwidth-iris'
gem 'goliath'
gem 'lazy_object'
gem 'log4r'
@@ -16,6 +20,7 @@ gem 'multihashes'
gem 'rack', '< 2'
gem 'redis'
gem "sentry-ruby", "<= 4.3.1"
+gem 'webrick'
group(:development) do
gem "pry-reload"
@@ -0,0 +1,84 @@
+# frozen_string_literal: true
+
+require "em_promise"
+require "ruby-bandwidth-iris"
+Faraday.default_adapter = :em_synchrony
+
+class BandwidthTNOptions
+ def self.set_port_out_pin(user_id, token, secret, pin, tel)
+ client = BandwidthIris::Client.new(
+ account_id: user_id,
+ username: token,
+ password: secret
+ )
+
+ data = {
+ tn_option_groups: {
+ tn_option_group: [
+ {
+ port_out_passcode: pin,
+ telephone_numbers: {
+ telephone_number: [tel]
+ }
+ }
+ ]
+ }
+ }
+
+ EMPromise.resolve(nil).then do
+ order = BandwidthIris::TnOptions.create_tn_option_order(client, data)
+ order_id = order[:order_id]
+
+ unless order_id
+ raise "Missing OrderId in create response"
+ end
+
+ poll_order(order_id, client)
+ end
+ end
+
+ def self.poll_order(order_id, client, attempt=1, max_attempts=30)
+ if attempt > max_attempts
+ return EMPromise.reject(RuntimeError.new(
+ "TnOptions polling timeout after #{max_attempts} attempts"
+ ))
+ end
+
+ EMPromise.resolve(nil).then do
+ order = BandwidthIris::TnOptions.get_tn_option_order(client, order_id)
+
+ error_list = order[:error_list]
+ if error_list&.any?
+ errors = [error_list].flatten
+ msgs = errors.map { |e| e.to_s }.reject(&:empty?).join('; ')
+ raise "Dashboard tnOptions errors: #{msgs}"
+ end
+
+ warnings = order[:warnings]
+ if warnings&.any?
+ warns = [warnings].flatten
+ descs = warns.map { |w| w.to_s }.reject(&:empty?).join('; ')
+ raise "Dashboard tnOptions warnings: #{descs}"
+ end
+
+ status = order[:order_status] || order[:processing_status]
+
+ case status&.to_s&.upcase
+ when 'COMPLETE', 'PARTIAL'
+ true
+ when 'FAILED'
+ raise "TnOptions order failed with status: #{status}"
+ when 'RECEIVED', 'PROCESSING'
+ EMPromise.new { |resolve, reject|
+ EM.add_timer(2) do
+ poll_order(order_id, client, attempt + 1, max_attempts)
+ .then { |result| resolve.call(result) }
+ .catch { |error| reject.call(error) }
+ end
+ }
+ else
+ raise "Unexpected poll status: #{status.inspect}"
+ end
+ end
+ end
+end
@@ -36,8 +36,10 @@ require 'goliath/server'
require 'log4r'
require 'em_promise'
+require 'em-synchrony'
require_relative 'lib/bandwidth_error'
+require_relative 'lib/bandwidth_tn_options'
require_relative 'lib/registration_repo'
Sentry.init
@@ -150,7 +152,8 @@ module SGXbwmsgsv2
@gateway_features = [
"http://jabber.org/protocol/disco#info",
"http://jabber.org/protocol/address/",
- "jabber:iq:register"
+ "jabber:iq:register",
+ "http://jabber.org/protocol/commands"
]
def self.run
@@ -534,6 +537,35 @@ module SGXbwmsgsv2
write_to_stream msg
end
+ disco_items(
+ to: Blather::JID.new(ARGV[0]),
+ node: "http://jabber.org/protocol/commands"
+ ) do |i|
+ # Ensure user is registered, but discard their credentials because we don't
+ # need them yet
+ fetch_catapult_cred_for(i.from).then { |_creds|
+ reply = i.reply
+ reply.node = 'http://jabber.org/protocol/commands'
+
+ reply.items = [
+ Blather::Stanza::DiscoItems::Item.new(
+ i.to,
+ 'set-port-out-pin',
+ 'Set Port-Out PIN'
+ )
+ ]
+
+ puts 'RESPONSE_CMD_DISCO: ' + reply.inspect
+ write_to_stream reply
+ }.catch { |e|
+ if e.is_a?(Array) && [2, 3].include?(e.length)
+ write_to_stream i.as_error(e[1], e[0], e[2])
+ else
+ EMPromise.reject(e)
+ end
+ }
+ end
+
iq '/iq/ns:query', ns: 'http://jabber.org/protocol/disco#info' do |i|
# TODO: return error if i.type is :set - if it is :reply or
# :error it should be ignored (as the below does currently);
@@ -720,6 +752,119 @@ module SGXbwmsgsv2
}.catch(&method(:panic))
end
+ command :execute?, node: "set-port-out-pin", sessionid: nil do |iq|
+ # Ensure user is registered, but discard their credentials because we don't
+ # need them yet
+ fetch_catapult_cred_for(iq.from).then { |_creds|
+ reply = iq.reply
+ reply.node = 'set-port-out-pin'
+ reply.sessionid = SecureRandom.uuid
+ reply.status = :executing
+
+ form = Blather::Stanza::X.find_or_create(reply.command)
+ form.type = "form"
+ form.fields = [
+ {
+ var: 'pin',
+ type: 'text-private',
+ label: 'Port-Out PIN',
+ required: true
+ },
+ {
+ var: 'confirm_pin',
+ type: 'text-private',
+ label: 'Confirm PIN',
+ required: true
+ }
+ ]
+
+ reply.command.add_child(form)
+ reply.allowed_actions = [:complete]
+
+ puts "RESPONSE_CMD_FORM: #{reply.inspect}"
+ write_to_stream reply
+ }.catch { |e|
+ if e.is_a?(Array) && [2, 3].include?(e.length)
+ write_to_stream iq.as_error(e[1], e[0], e[2])
+ else
+ EMPromise.reject(e)
+ end
+ }.catch(&method(:panic))
+ end
+
+ command :complete?, node: "set-port-out-pin", sessionid: /./ do |iq|
+ pin = iq.form.field('pin')&.value
+ confirm_pin = iq.form.field('confirm_pin')&.value
+
+ if pin.nil? || confirm_pin.nil?
+ write_to_stream iq.as_error(
+ 'bad-request',
+ :modify,
+ 'PIN fields are required'
+ )
+ next
+ end
+
+ if pin != confirm_pin
+ write_to_stream iq.as_error(
+ 'bad-request',
+ :modify,
+ 'PIN confirmation does not match'
+ )
+ next
+ end
+
+ if pin !~ /\A[a-zA-Z0-9]{4,10}\z/
+ write_to_stream iq.as_error(
+ 'bad-request',
+ :modify,
+ 'PIN must be 4-10 alphanumeric characters'
+ )
+ next
+ end
+
+ fetch_catapult_cred_for(iq.from).then { |creds|
+ user_id, token, secret, phone_num = creds
+
+ # Stripping +1 like this feels janky, but Bandwidth only deals in +1
+ # numbers and this is a Bandwidth SGX, so it's fine.
+ phone_num_local = phone_num.sub(/^\+1/, '')
+ BandwidthTNOptions.set_port_out_pin(user_id, token, secret, pin, phone_num_local).then {
+ reply = iq.reply
+ reply.node = 'set-port-out-pin'
+ reply.sessionid = iq.sessionid
+ reply.status = :completed
+ reply.note_type = :info
+ reply.note_text = 'Port-out PIN has been set successfully.'
+
+ write_to_stream reply
+ }.catch { |e|
+ reply = iq.reply
+ reply.node = 'set-port-out-pin'
+ reply.sessionid = iq.sessionid
+ reply.status = :completed
+ reply.note_type = :error
+ error_msg = if e.respond_to?(:message) && e.message.include?('not valid')
+ "Invalid phone number format. "\
+ "Please check your registered phone number."
+ elsif e.respond_to?(:message) && e.message.include?('ErrorCode')
+ "Bandwidth API error: #{e.message}"
+ else
+ "Failed to set port-out PIN. Please try again later."
+ end
+ reply.note_text = error_msg
+
+ write_to_stream reply
+ }
+ }.catch { |e|
+ if e.is_a?(Array) && [2, 3].include?(e.length)
+ write_to_stream iq.as_error(e[1], e[0], e[2])
+ else
+ EMPromise.reject(e)
+ end
+ }.catch(&method(:panic))
+ end
+
iq type: [:get, :set] do |iq|
write_to_stream(Blather::StanzaError.new(
iq,
@@ -404,4 +404,113 @@ class ComponentTest < Minitest::Test
refute stanza.email
end
em :test_ibr_get_form_registered
+
+ def test_port_out_pin
+ iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
+ iq.from = 'test@example.com'
+ iq.node = 'set-port-out-pin'
+ iq.sessionid = 'test-session-123'
+ iq.action = :complete
+ iq.form.type = :submit
+ iq.form.fields = [
+ {
+ var: 'pin',
+ value: '1234'
+ },
+ {
+ var: 'confirm_pin',
+ value: '1234'
+ }
+ ]
+ end
+
+ BandwidthIris::TnOptions.stub :create_tn_option_order,
+ ->(client, data) { {order_id: 'test-order-123', processing_status: 'RECEIVED', error_list: {}} } do
+ BandwidthIris::TnOptions.stub :get_tn_option_order,
+ ->(client, order_id) { {order_id: order_id, order_status: 'COMPLETE', error_list: {}} } do
+
+ process_stanza(iq)
+
+ assert_equal 1, written.length
+
+ stanza = Blather::XMPPNode.parse(written.first.to_xml)
+ refute stanza.error?
+ end
+ end
+ end
+ em :test_port_out_pin
+
+ def test_port_out_pin_mismatch
+ iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
+ iq.from = 'test@example.com'
+ iq.node = 'set-port-out-pin'
+ iq.sessionid = 'test-session-mismatch'
+ iq.action = :complete
+ iq.form.type = :submit
+ iq.form.fields = [
+ {
+ var: 'pin',
+ value: '1234'
+ },
+ {
+ var: 'confirm_pin',
+ value: '5678'
+ }
+ ]
+ end
+
+ process_stanza(iq)
+
+ assert_equal 1, written.length
+
+ stanza = Blather::XMPPNode.parse(written.first.to_xml)
+ assert_equal :error, stanza.type
+ error = stanza.find_first("error")
+ assert_equal "modify", error["type"]
+ assert_equal "bad-request", xmpp_error_name(error)
+ assert_equal "PIN confirmation does not match", xmpp_error_text(error)
+ end
+ em :test_port_out_pin_mismatch
+
+ def test_port_out_pin_validation
+ [
+ ['123', 'PIN must be 4-10 alphanumeric characters'],
+ ['12345678901', 'PIN must be 4-10 alphanumeric characters'],
+ ['123!', 'PIN must be 4-10 alphanumeric characters'],
+ ['pin with spaces', 'PIN must be 4-10 alphanumeric characters']
+ ].each do |invalid_pin, expected_error|
+ iq = Blather::Stanza::Iq::Command.new(:set, 'component').tap do |iq|
+ iq.from = 'test@example.com'
+ iq.node = 'set-port-out-pin'
+ iq.sessionid = "test-session-validation-#{invalid_pin.gsub(/[^a-zA-Z0-9]/, '')}"
+ iq.action = :complete
+ iq.form.type = :submit
+ iq.form.fields = [
+ {
+ var: 'pin',
+ value: invalid_pin
+ },
+ {
+ var: 'confirm_pin',
+ value: invalid_pin
+ }
+ ]
+ end
+
+ process_stanza(iq)
+
+ assert_equal 1, written.length, "Failed for PIN: #{invalid_pin}"
+
+ stanza = Blather::XMPPNode.parse(written.first.to_xml)
+ assert_equal :error, stanza.type, "Expected error for PIN: #{invalid_pin}"
+ error = stanza.find_first("error")
+ assert_equal "modify", error["type"]
+ assert_equal "bad-request", xmpp_error_name(error)
+ assert_equal expected_error, xmpp_error_text(error),
+ "Wrong error message for PIN: #{invalid_pin}"
+
+ SGXbwmsgsv2.instance_variable_set(:@written, [])
+ end
+ end
+ em :test_port_out_pin_validation
end
@@ -25,7 +25,7 @@ begin
end
end
end
-rescue LoadError
+rescue LoadError, NameError
# Just helpers for dev, no big deal if missing
nil
end