~singpolyma/sgx-jmp

2169e8eaab34ad92a8b727bc59aae08c64bbe4af — Stephen Paul Weber 1 year, 1 month ago 93b7dea + 192d092
Merge branch 'configure-calls-v2'

* configure-calls-v2:
  New configure calls command
  Move more persistence into the repo layer
  Easy DSL for adding XEP-0122 validation to fields
  CustomerFwd uses ValueSemantics, translates old XMPP-SIP URI
A forms/configure_calls.rb => forms/configure_calls.rb +48 -0
@@ 0,0 1,48 @@
form!
title "Configure Calls"

field(
	var: "fwd[timeout]",
	type: "text-single",
	datatype: "xs:integer",
	label: "Seconds to ring before voicemail",
	description: "One ring is ~5 seconds. Negative means ring forever.",
	value: @customer.fwd.timeout.to_i.to_s
)

field(
	var: "voicemail_transcription",
	type: "boolean",
	label: "Voicemail transcription",
	value: @customer.transcription_enabled.to_s
)

field(
	var: "fwd[uri]",
	type: "list-single",
	datatype: "xs:anyURI",
	open: true,
	label: "Forward calls to",
	description: "List item or any custom xmpp:, sip:, or tel: URI.",
	options: [
		{ label: "Jabber ID", value: "xmpp:#{@customer.jid}" },
		{ label: "SIP Account", value: @customer.sip_account.uri }
	],
	value: @customer.fwd.uri
)

if @customer.tndetails.dig(:features, :lidb)
	field(
		var: "lidb_name",
		type: "fixed",
		label: "CNAM",
		value: "#{@lidb[:name]} (#{@lidb[:status]})"
	)
elsif @customer.tndetails[:on_net_vendor]
	field(
		var: "lidb_name",
		type: "text-single",
		label: "CNAM Name",
		description: "or nothing/space to leave blank"
	)
end

M lib/backend_sgx.rb => lib/backend_sgx.rb +0 -8
@@ 36,14 36,6 @@ class BackendSgx
		end
	end

	def set_fwd(uri)
		REDIS.set("catapult_fwd-#{registered?.phone}", uri)
	end

	def set_fwd_timeout(timeout)
		REDIS.set("catapult_fwd_timeout-#{from_jid}", timeout)
	end

	def set_ogm_url(url)
		REDIS.set("catapult_ogm_url-#{from_jid}", url)
	end

M lib/bwmsgsv2_repo.rb => lib/bwmsgsv2_repo.rb +29 -1
@@ 20,15 20,43 @@ class Bwmsgsv2Repo
		fetch_raw(sgx.from_jid).then do |(((ogm_url, fwd_time, fwd), trans_d), reg)|
			sgx.with({
				ogm_url: ogm_url,
				fwd: CustomerFwd.for(fwd, fwd_time),
				fwd: CustomerFwd.for(uri: fwd, timeout: fwd_time),
				transcription_enabled: !trans_d,
				registered?: reg
			}.compact)
		end
	end

	def put_transcription_enabled(customer_id, enabled)
		sgx = @trivial_repo.get(customer_id)
		REDIS.setbit(
			"catapult_settings_flags-#{sgx.from_jid}",
			Bwmsgsv2Repo::VOICEMAIL_TRANSCRIPTION_DISABLED,
			enabled ? 0 : 1
		)
	end

	def put_fwd(customer_id, tel, customer_fwd)
		sgx = @trivial_repo.get(customer_id)
		EMPromise.all([
			set_or_delete("catapult_fwd-#{tel}", customer_fwd.uri),
			set_or_delete(
				"catapult_fwd_timeout-#{sgx.from_jid}",
				customer_fwd.timeout.to_i
			)
		])
	end

protected

	def set_or_delete(k, v)
		if v.nil?
			REDIS.del(k)
		else
			REDIS.set(k, v)
		end
	end

	def fetch_raw(from_jid)
		registration(from_jid).then do |r|
			EMPromise.all([from_redis(from_jid, r ? r.phone : nil), r])

A lib/configure_calls_form.rb => lib/configure_calls_form.rb +40 -0
@@ 0,0 1,40 @@
# frozen_string_literal: true

require_relative "form_to_h"

class ConfigureCallsForm
	using FormToH

	def initialize(customer)
		@customer = customer
	end

	def render
		FormTemplate.render("configure_calls", customer: @customer)
	end

	def parse(form)
		params = form.to_h
		{}.tap do |result|
			result[:fwd] = parse_fwd(params["fwd"]) if params.key?("fwd")
			if params.key?("voicemail_transcription")
				result[:transcription_enabled] =
					["1", "true"].include?(params["voicemail_transcription"])
			end
			result[:lidb_name] = params["lidb_name"] if lidb_guard(params["lidb_name"])
		end
	end

protected

	def lidb_guard(lidb_name)
		!lidb_name.to_s.strip.empty? &&
			!@customer.tndetails.dig(:features, :lidb)
	end

	def parse_fwd(fwd_from_form)
		fwd_from_form.reduce(@customer.fwd) do |fwd, (var, val)|
			fwd.with(var.to_sym => val)
		end
	end
end

M lib/customer.rb => lib/customer.rb +6 -1
@@ 23,7 23,7 @@ class Customer
	def_delegators :@plan, :active?, :activate_plan_starting_now, :bill_plan,
	               :currency, :merchant_account, :plan_name, :auto_top_up_amount
	def_delegators :@sgx, :register!, :registered?, :set_ogm_url,
	               :set_fwd, :fwd, :transcription_enabled
	               :fwd, :transcription_enabled
	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage

	def initialize(


@@ 83,6 83,11 @@ class Customer
		stanza_to(iq, &IQ_MANAGER.method(:write)).then(&:vcard)
	end

	def tndetails
		@tndetails ||=
			BandwidthIris::Tn.new(telephone_number: registered?.phone).get_details
	end

	def ogm(from_tel=nil)
		CustomerOGM.for(@sgx.ogm_url, -> { fetch_vcard_temp(from_tel) })
	end

M lib/customer_fwd.rb => lib/customer_fwd.rb +35 -30
@@ 1,17 1,25 @@
# frozen_string_literal: true

require "value_semantics/monkey_patched"
require "uri"

class CustomerFwd
	def self.for(uri, timeout)
	def self.for(uri:, timeout:)
		timeout = Timeout.new(timeout)
		return if !uri || timeout.zero?
		return None.new(uri: uri, timeout: timeout) if !uri || timeout.zero?
		if uri =~ /\Asip:(.*)@sip.cheogram.com\Z/
			uri = "xmpp:#{$1.gsub(/%([0-9A-F]{2})/i) { $1.to_i(16).chr }}"
		end
		URIS.fetch(uri.split(":", 2).first.to_sym) {
			raise "Unknown forward URI: #{uri}"
		}.new(uri, timeout)
		}.new(uri: uri, timeout: timeout)
	end

	class Timeout
		def self.new(s)
			s.is_a?(self) ? s : super
		end

		def initialize(s)
			@timeout = s.nil? || s.to_i.negative? ? 300 : s.to_i
		end


@@ 25,54 33,51 @@ class CustomerFwd
		end
	end

	def create_call_request
	value_semantics do
		uri Either(String, NilClass)
		# rubocop:disable Style/RedundantSelf
		self.timeout Timeout, coerce: Timeout.method(:new)
		# rubocop:enable Style/RedundantSelf
	end

	def with(new_attrs)
		CustomerFwd.for(to_h.merge(new_attrs))
	end

	def create_call(account)
		request = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
			cc.to = to
			cc.call_timeout = timeout.to_i
			yield cc if block_given?
		end
		yield request if block_given?
		request
		BANDWIDTH_VOICE.create_call(account, body: request).data.call_id
	end

	class Tel < CustomerFwd
		attr_reader :timeout

		def initialize(uri, timeout)
			@tel = uri.sub(/^tel:/, "")
			@timeout = timeout
		end

		def to
			@tel
			uri.sub(/^tel:/, "")
		end
	end

	class SIP < CustomerFwd
		attr_reader :timeout

		def initialize(uri, timeout)
			@uri = uri
			@timeout = timeout
		end

		def to
			@uri
			uri
		end
	end

	class XMPP < CustomerFwd
		attr_reader :timeout

		def initialize(uri, timeout)
			@jid = uri.sub(/^xmpp:/, "")
			@timeout = timeout
		end

		def to
			"sip:#{ERB::Util.url_encode(@jid)}@sip.cheogram.com"
			jid = uri.sub(/^xmpp:/, "")
			"sip:#{ERB::Util.url_encode(jid)}@sip.cheogram.com"
		end
	end

	class None < CustomerFwd
		def create_call; end

		def to; end
	end

	URIS = {
		tel: Tel,
		sip: SIP,

M lib/customer_repo.rb => lib/customer_repo.rb +27 -3
@@ 59,12 59,36 @@ class CustomerRepo
		end
	end

	def put_lidb_name(customer, lidb_name)
		BandwidthIris::Lidb.create(
			customer_order_id: customer.customer_id,
			lidb_tn_groups: { lidb_tn_group: {
				telephone_numbers: [customer.registered?.phone.sub(/\A\+1/, "")],
				subscriber_information: lidb_name,
				use_type: "RESIDENTIAL",
				visibility: "PUBLIC"
			} }
		)
	end

	def put_transcription_enabled(customer, transcription_enabled)
		@sgx_repo.put_transcription_enabled(
			customer.customer_id, transcription_enabled
		)
	end

	def put_fwd(customer, customer_fwd)
		@sgx_repo.put_fwd(
			customer.customer_id,
			customer.registered?.phone,
			customer_fwd
		)
	end

protected

	def new_sgx(customer_id)
		TrivialBackendSgxRepo.new.get(customer_id).with(
			registered?: false
		)
		TrivialBackendSgxRepo.new.get(customer_id).with(registered?: false)
	end

	def find_legacy_customer(jid)

M lib/form_template.rb => lib/form_template.rb +24 -2
@@ 48,8 48,30 @@ class FormTemplate
			@__form.instructions = s
		end

		def field(**kwargs)
			@__form.fields = @__form.fields + [kwargs]
		def validate(f, datatype: nil, **kwargs)
			Nokogiri::XML::Builder.with(f) do |x|
				x.validate(
					xmlns: "http://jabber.org/protocol/xdata-validate",
					datatype: datatype || "xs:string"
				) do
					x.basic unless validation_type(x, **kwargs)
				end
			end
		end

		def validation_type(x, open: false, regex: nil, range: nil)
			x.open if open
			x.range(min: range.first, max: range.last) if range
			x.regex(regex.source) if regex
			open || regex || range
		end

		def field(datatype: nil, open: false, regex: nil, range: nil, **kwargs)
			f = Blather::Stanza::X::Field.new(kwargs)
			if datatype || open || regex || range
				validate(f, datatype: datatype, open: open, regex: regex, range: range)
			end
			@__form.fields += [f]
		end

		def xml

M lib/registration.rb => lib/registration.rb +4 -7
@@ 445,10 445,6 @@ class Registration
			}.then { |tel| Finish.new(@customer, tel).write }
		end

		def cheogram_sip_addr
			"sip:#{ERB::Util.url_encode(@customer.jid)}@sip.cheogram.com"
		end

		def raise_setup_error(e)
			Command.log.error "@customer.register! failed", e
			Command.finish(


@@ 459,11 455,12 @@ class Registration
		end

		def customer_active_tel_purchased
			@customer.register!(@tel).catch(&method(:raise_setup_error)).then { |sgx|
			@customer.register!(@tel).catch(&method(:raise_setup_error)).then {
				EMPromise.all([
					REDIS.del("pending_tel_for-#{@customer.jid}"),
					sgx.set_fwd(cheogram_sip_addr),
					sgx.set_fwd_timeout(25) # ~5 seconds / ring, 5 rings
					Bwmsgsv2Repo.new.put_fwd(@customer.customer_id, @tel, CustomerFwd.for(
						uri: "xmpp:#{@customer.jid}", timeout: 25 # ~5 seconds / ring, 5 rings
					))
				])
			}.then do
				Command.finish("Your JMP account has been activated as #{@tel}")

M sgx_jmp.rb => sgx_jmp.rb +24 -14
@@ 73,6 73,7 @@ require_relative "lib/bwmsgsv2_repo"
require_relative "lib/bandwidth_tn_order"
require_relative "lib/btc_sell_prices"
require_relative "lib/buy_account_credit_form"
require_relative "lib/configure_calls_form"
require_relative "lib/command"
require_relative "lib/command_list"
require_relative "lib/customer"


@@ 453,16 454,25 @@ Command.new(
	end
}.register(self).then(&CommandList.method(:register))

# Commands that just pass through to the SGX
{
	"configure-calls" => ["Configure Calls"]
}.each do |node, args|
	Command.new(node, *args) {
		Command.customer.then do |customer|
			customer.stanza_from(Command.execution.iq)
Command.new(
	"configure calls",
	"Configure Calls",
	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
) {
	Command.customer.then do |customer|
		cc_form = ConfigureCallsForm.new(customer)
		Command.reply { |reply|
			reply.allowed_actions = [:next]
			reply.command << cc_form.render
		}.then { |iq|
			EMPromise.all(cc_form.parse(iq.form).map { |k, v|
				Command.execution.customer_repo.public_send("put_#{k}", customer, v)
			})
		}.then do
			Command.finish("Configuration saved!")
		end
	}.register(self, guards: [node: node]).then(&CommandList.method(:register))
end
	end
}.register(self).then(&CommandList.method(:register))

Command.new(
	"ogm",


@@ 617,12 627,12 @@ Command.new(
			if ["1", "true"].include?(fwd.form.field("change_fwd")&.value.to_s)
				# Migrate location if needed
				BandwidthIris::SipPeer.new(
					site_id: CONFIG[:bandwidth_site],
					id: CONFIG[:bandwidth_peer]
					site_id: CONFIG[:bandwidth_site], id: CONFIG[:bandwidth_peer]
				).move_tns([customer.registered?.phone])
				customer.set_fwd(sip_account.uri).then do
					Command.finish("Inbound calls will now forward to SIP.")
				end
				Command.execution.customer_repo.put_fwd(
					customer,
					customer.fwd.with(uri: sip_account.uri)
				).then { Command.finish("Inbound calls will now forward to SIP.") }
			else
				Command.finish
			end

A test/test_customer_fwd.rb => test/test_customer_fwd.rb +51 -0
@@ 0,0 1,51 @@
# frozen_string_literal: true

require "test_helper"
require "customer_fwd"

class Rantly
	def jid
		v = Blather::JID.new(Blather::JID.new(string, string).stripped.to_s)
		guard !v.to_s.to_s.empty?
		v
	end
end

class CustomerFwdTest < Minitest::Test
	property(:for_xmpp) { jid }
	def for_xmpp(jid)
		sip = "sip:#{ERB::Util.url_encode(jid.to_s)}@sip.cheogram.com"
		fwd = CustomerFwd.for(uri: "xmpp:#{jid}", timeout: 10)
		assert_kind_of CustomerFwd::XMPP, fwd
		assert_equal sip, fwd.to
	end

	property(:for_xmpp_sip) { jid }
	def for_xmpp_sip(jid)
		sip = "sip:#{ERB::Util.url_encode(jid.to_s)}@sip.cheogram.com"
		fwd = CustomerFwd.for(uri: sip, timeout: 10)
		assert_kind_of CustomerFwd::XMPP, fwd
		assert_equal sip, fwd.to
	end

	property(:for_tel) { "+#{string(:digit)}" }
	def for_tel(tel)
		fwd = CustomerFwd.for(uri: "tel:#{tel}", timeout: 10)
		assert_kind_of CustomerFwd::Tel, fwd
		assert_equal tel, fwd.to
	end

	property(:for_sip) { "#{string(:alnum)}@#{string(:alnum)}.example.com" }
	def for_sip(sip)
		fwd = CustomerFwd.for(uri: "sip:#{sip}", timeout: 10)
		assert_kind_of CustomerFwd::SIP, fwd
		assert_equal "sip:#{sip}", fwd.to
	end

	property(:for_bogus) { string }
	def for_bogus(bogus)
		assert_raises(RuntimeError) do
			CustomerFwd.for(uri: "bogus:#{bogus}", timeout: 10)
		end
	end
end

M test/test_customer_repo.rb => test/test_customer_repo.rb +80 -1
@@ 3,6 3,10 @@
require "test_helper"
require "customer_repo"

class CustomerRepo
	attr_reader :sgx_repo
end

class CustomerRepoTest < Minitest::Test
	FAKE_REDIS = FakeRedis.new(
		# sgx-jmp customer


@@ 51,7 55,13 @@ class CustomerRepoTest < Minitest::Test
		db: FAKE_DB,
		braintree: Minitest::Mock.new
	)
		CustomerRepo.new(redis: redis, db: db, braintree: braintree)
		sgx_repo = Minitest::Mock.new(TrivialBackendSgxRepo.new)
		CustomerRepo.new(
			redis: redis,
			db: db,
			braintree: braintree,
			sgx_repo: sgx_repo
		)
	end

	def setup


@@ 153,4 163,73 @@ class CustomerRepoTest < Minitest::Test
		assert_mock redis
	end
	em :test_create

	def test_put_lidb_name
		post = stub_request(
			:post,
			"https://dashboard.bandwidth.com/v1.0/accounts//lidbs"
		).with(body: {
			CustomerOrderId: "test",
			LidbTnGroups: {
				LidbTnGroup: {
					TelephoneNumbers: "5556667777",
					SubscriberInformation: "Hank",
					UseType: "RESIDENTIAL",
					Visibility: "PUBLIC"
				}
			}
		}.to_xml(root: "LidbOrder", indent: 0)).to_return(
			status: 201,
			headers: { location: "/boop/123" }
		)

		stub_request(
			:get,
			"https://dashboard.bandwidth.com/v1.0/accounts//lidbs/123"
		)

		@repo.put_lidb_name(
			Customer.new(
				"test",
				"test@exmple.com",
				sgx: OpenStruct.new(registered?: OpenStruct.new(phone: "+15556667777"))
			),
			"Hank"
		)

		assert_requested post
	end
	em :test_put_lidb_name

	def test_put_transcription_enabled
		@repo.sgx_repo.expect(
			:put_transcription_enabled,
			EMPromise.resolve(nil),
			["test", true]
		)
		@repo.put_transcription_enabled(
			Customer.new("test", "test@exmple.com"),
			true
		)
		assert_mock @repo.sgx_repo
	end
	em :test_put_transcription_enabled

	def test_put_fwd
		@repo.sgx_repo.expect(
			:put_fwd,
			EMPromise.resolve(nil),
			["test", "+15556667777", :fwd]
		)
		@repo.put_fwd(
			Customer.new(
				"test",
				"test@exmple.com",
				sgx: OpenStruct.new(registered?: OpenStruct.new(phone: "+15556667777"))
			),
			:fwd
		)
		assert_mock @repo.sgx_repo
	end
	em :test_put_fwd
end

M test/test_form_template.rb => test/test_form_template.rb +68 -0
@@ 46,6 46,74 @@ class FormTemplateTest < Minitest::Test
		assert_equal "INSTRUCTIONS", form.instructions
	end

	def test_form_validate_basic
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			field(var: "thevar", label: "thelabel", datatype: "xs:integer")
		TEMPLATE
		form = template.render
		assert_equal 1, form.fields.length
		assert_equal "thevar", form.fields[0].var
		assert_equal "thelabel", form.fields[0].label
		validate = form.fields[0].find(
			"ns:validate",
			ns: "http://jabber.org/protocol/xdata-validate"
		).first
		assert_equal "xs:integer", validate[:datatype]
		assert_equal "basic", validate.children.first.name
	end

	def test_form_validate_open
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			field(var: "thevar", label: "thelabel", open: true)
		TEMPLATE
		form = template.render
		assert_equal 1, form.fields.length
		assert_equal "thevar", form.fields[0].var
		assert_equal "thelabel", form.fields[0].label
		validate = form.fields[0].find(
			"ns:validate",
			ns: "http://jabber.org/protocol/xdata-validate"
		).first
		assert_equal ["open"], validate.children.map(&:name)
	end

	def test_form_validate_regex
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			field(var: "thevar", label: "thelabel", regex: /[A-Z]/)
		TEMPLATE
		form = template.render
		assert_equal 1, form.fields.length
		assert_equal "thevar", form.fields[0].var
		assert_equal "thelabel", form.fields[0].label
		validate = form.fields[0].find(
			"ns:validate",
			ns: "http://jabber.org/protocol/xdata-validate"
		).first
		assert_equal ["regex"], validate.children.map(&:name)
		assert_equal "[A-Z]", validate.children.first.content
	end

	def test_form_validate_range
		template = FormTemplate.new(<<~TEMPLATE)
			form!
			field(var: "thevar", label: "thelabel", range: (10..22))
		TEMPLATE
		form = template.render
		assert_equal 1, form.fields.length
		assert_equal "thevar", form.fields[0].var
		assert_equal "thelabel", form.fields[0].label
		validate = form.fields[0].find(
			"ns:validate",
			ns: "http://jabber.org/protocol/xdata-validate"
		).first
		assert_equal ["range"], validate.children.map(&:name)
		assert_equal "10", validate.children.first[:min]
		assert_equal "22", validate.children.first[:max]
	end

	def test_no_type
		template = FormTemplate.new(<<~TEMPLATE)
			title "TITLE"

M test/test_registration.rb => test/test_registration.rb +5 -5
@@ 517,7 517,7 @@ class RegistrationTest < 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
		Bwmsgsv2Repo::REDIS = Minitest::Mock.new

		def setup
			@sgx = Minitest::Mock.new(TrivialBackendSgxRepo.new.get("test"))


@@ 568,15 568,15 @@ class RegistrationTest < Minitest::Test
				nil,
				["pending_tel_for-test@example.net"]
			)
			BackendSgx::REDIS.expect(
			Bwmsgsv2Repo::REDIS.expect(
				:set,
				nil,
				[
					"catapult_fwd-+15555550000",
					"sip:test%40example.net@sip.cheogram.com"
					"xmpp:test@example.net"
				]
			)
			BackendSgx::REDIS.expect(
			Bwmsgsv2Repo::REDIS.expect(
				:set,
				nil,
				["catapult_fwd_timeout-customer_test@component", 25]


@@ 608,7 608,7 @@ class RegistrationTest < Minitest::Test
			assert_requested create_order
			assert_mock @sgx
			assert_mock Registration::Finish::REDIS
			assert_mock BackendSgx::REDIS
			assert_mock Bwmsgsv2Repo::REDIS
			assert_mock blather
		end
		em :test_write

M web.rb => web.rb +9 -10
@@ 257,17 257,16 @@ class Web < Roda
					CustomerRepo.new(
						sgx_repo: Bwmsgsv2Repo.new
					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
						if fwd
						call = fwd.create_call(CONFIG[:creds][:account]) do |cc|
							true_inbound_call[pseudo_call_id] = params["callId"]
							outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
								CONFIG[:creds][:account],
								body: fwd.create_call_request do |cc|
									cc.from = params["from"]
									cc.application_id = params["applicationId"]
									cc.answer_url = url inbound_calls_path(nil)
									cc.disconnect_url = url inbound_calls_path(:transfer_complete)
								end
							).data.call_id
							cc.from = params["from"]
							cc.application_id = params["applicationId"]
							cc.answer_url = url inbound_calls_path(nil)
							cc.disconnect_url = url inbound_calls_path(:transfer_complete)
						end

						if call
							outbound_transfers[pseudo_call_id] = call
							render :pause, locals: { duration: 300 }
						else
							render :redirect, locals: { to: inbound_calls_path(:voicemail) }