~singpolyma/sgx-jmp

f6896d17e30f8ded521beecd8107b17951c6ed10 — Stephen Paul Weber 1 year, 7 months ago 768bdab
Move CustomerFwd behind Customer

All the previously-lazy BackendSgx data is now either all loaded or all not
loaded by swapping the sgx_repo used by your CustomerRepo instance.  When not
loaded the fields are filled with bottom values that explode when used.  When
loaded the values are present in RAM and not promises at all.  Most code paths
do not need any of the data, a few need most of it, so this seems like a good
trade-off.  Most code using this object will simply never touch those fields or
care about how they are loaded, etc.

Of course, most of this data isn't even SGX related and should move out of here,
but that would take a data model refactor/migration on the catapult_* schema.
M lib/backend_sgx.rb => lib/backend_sgx.rb +20 -51
@@ 1,71 1,40 @@
# frozen_string_literal: true

class BackendSgx
	VOICEMAIL_TRANSCRIPTION_DISABLED = 0
require "value_semantics/monkey_patched"

require_relative "customer_fwd"
require_relative "ibr"
require_relative "not_loaded"

	def initialize(customer_id, jid=CONFIG[:sgx], creds=CONFIG[:creds])
		@customer_id = customer_id
		@jid = jid
		@creds = creds
class BackendSgx
	value_semantics do
		jid Blather::JID
		creds HashOf(Symbol => String)
		from_jid Blather::JID
		ogm_url Either(String, nil, NotLoaded)
		fwd Either(CustomerFwd, nil, NotLoaded)
		transcription_enabled Either(Bool(), NotLoaded)
		registered? Either(IBR, FalseClass, NotLoaded)
	end

	def register!(tel)
		ibr = mkibr(:set)
		ibr.nick = @creds[:account]
		ibr.username = @creds[:username]
		ibr.password = @creds[:password]
		ibr = IBR.new(:set, @jid)
		ibr.from = from_jid
		ibr.nick = creds[:account]
		ibr.username = creds[:username]
		ibr.password = creds[:password]
		ibr.phone = tel
		IQ_MANAGER.write(ibr)
	end

	def registered?
		IQ_MANAGER.write(mkibr(:get)).catch { nil }.then do |result|
			if result&.respond_to?(:registered?) && result&.registered?
				result
			else
				false
			end
		end
	end

	def stanza(s)
		s.dup.tap do |stanza|
			stanza.to = stanza.to.with(domain: @jid)
			stanza.to = stanza.to.with(domain: jid.domain)
			stanza.from = from_jid.with(resource: stanza.from.resource)
		end
	end

	def ogm_url
		REDIS.get("catapult_ogm_url-#{from_jid}")
	end

	def catapult_flag(flagbit)
		REDIS.getbit(
			"catapult_setting_flags-#{from_jid}",
			flagbit
		).then { |x| x == 1 }
	end

	def fwd_timeout
		REDIS.get("catapult_fwd_timeout-#{from_jid}")
	end

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

protected

	def from_jid
		Blather::JID.new(
			"customer_#{@customer_id}",
			CONFIG[:component][:jid]
		)
	end

	def mkibr(type)
		ibr = IBR.new(type, @jid)
		ibr.from = from_jid
		ibr
	end
end

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

require "lazy_object"

require_relative "customer_fwd"
require_relative "ibr"
require_relative "trivial_backend_sgx_repo"

class Bwmsgsv2Repo
	VOICEMAIL_TRANSCRIPTION_DISABLED = 0

	def initialize(jid: CONFIG[:sgx], redis: LazyObject.new { REDIS }, **kwargs)
		@jid = jid
		@redis = redis
		@trivial_repo = TrivialBackendSgxRepo.new(jid: jid, **kwargs)
	end

	def get(customer_id)
		sgx = @trivial_repo.get(customer_id)
		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),
				transcription_enabled: !trans_d,
				registered?: reg
			}.compact)
		end
	end

protected

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

	def registration(from_jid)
		ibr = IBR.new(:get, @jid)
		ibr.from = from_jid

		IQ_MANAGER.write(ibr).catch { nil }.then do |result|
			if result&.respond_to?(:registered?) && result&.registered?
				result
			else
				false
			end
		end
	end

	def from_redis(from_jid, tel)
		EMPromise.all([
			@redis.mget(*[
				"catapult_ogm_url-#{from_jid}",
				"catapult_fwd_timeout-#{from_jid}",
				("catapult_fwd-#{tel}" if tel)
			].compact),
			@redis.getbit(
				"catapult_setting_flags-#{from_jid}", VOICEMAIL_TRANSCRIPTION_DISABLED
			).then { |x| x == 1 }
		])
	end
end

M lib/command.rb => lib/command.rb +6 -6
@@ 53,7 53,7 @@ class Command
			EMPromise.resolve(nil).then {
				Thread.current[:execution] = self
				sentry_hub
				catch_after(yield self)
				catch_after(EMPromise.resolve(yield self))
			}.catch(&method(:panic))
		end



@@ 141,21 141,21 @@ class Command
	def initialize(
		node,
		name,
		customer_repo: CustomerRepo.new,
		list_for: ->(tel:, **) { !!tel },
		format_error: ->(e) { e.respond_to?(:message) ? e.message : e.to_s },
		&blk
		format_error: ->(e) { e.respond_to?(:message) ? e.message : e.to_s }
	)
		@node = node
		@name = name
		@customer_repo = customer_repo
		@list_for = list_for
		@format_error = format_error
		@blk = blk
		@blk = ->(exe) { yield exe }
	end

	def register(blather, guards: [:execute?, node: @node, sessionid: nil])
		blather.command(*guards) do |iq|
			customer_repo = CustomerRepo.new
			Execution.new(customer_repo, blather, @format_error, iq).execute(&@blk)
			Execution.new(@customer_repo, blather, @format_error, iq).execute(&@blk)
		end
		self
	end

M lib/command_list.rb => lib/command_list.rb +12 -12
@@ 9,22 9,22 @@ class CommandList
	end

	def self.for(customer)
		EMPromise.resolve(customer&.registered?).catch { nil }.then do |reg|
			args_for(customer, reg).then do |kwargs|
				new(@commands.select { |c| c.list_for?(**kwargs) })
			end
		args_for(customer).then do |kwargs|
			new(@commands.select { |c| c.list_for?(**kwargs) })
		end
	end

	def self.args_for(customer, reg)
		args = { customer: customer, tel: reg ? reg.phone : nil }
		return EMPromise.resolve(args) unless args[:tel]
	def self.args_for(customer)
		args = {
			customer: customer,
			tel: customer&.registered? ? customer&.registered?&.phone : nil,
			fwd: customer&.fwd,
			payment_methods: []
		}
		return EMPromise.resolve(args) unless customer&.plan_name

		EMPromise.all([
			REDIS.get("catapult_fwd-#{args[:tel]}"),
			customer.plan_name ? customer.payment_methods : []
		]).then do |(fwd, payment_methods)|
			args.merge(fwd: fwd, payment_methods: payment_methods)
		customer.payment_methods.then do |payment_methods|
			args.merge(payment_methods: payment_methods)
		end
	end


M lib/customer.rb => lib/customer.rb +4 -5
@@ 14,6 14,7 @@ require_relative "./payment_methods"
require_relative "./plan"
require_relative "./proxied_jid"
require_relative "./sip_account"
require_relative "./trivial_backend_sgx_repo"

class Customer
	extend Forwardable


@@ 22,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?,
	               :fwd_timeout, :set_fwd_timeout, :catapult_flag
	               :set_fwd_timeout, :fwd, :transcription_enabled
	def_delegators :@usage, :usage_report, :message_usage, :incr_message_usage

	def initialize(


@@ 30,7 31,7 @@ class Customer
		jid,
		plan: CustomerPlan.new(customer_id),
		balance: BigDecimal(0),
		sgx: BackendSgx.new(customer_id)
		sgx: TrivialBackendSgxRepo.new.get(customer_id)
	)
		@plan = plan
		@usage = CustomerUsage.new(customer_id)


@@ 83,9 84,7 @@ class Customer
	end

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

	def sip_account

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

require "uri"

class CustomerFwd
	def self.for(uri, timeout)
		timeout = Timeout.new(timeout)
		return if !uri || timeout.zero?
		URIS.fetch(uri.split(":", 2).first.to_sym) {
			raise "Unknown forward URI: #{uri}"
		}.new(uri, timeout)
	end

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

		def zero?
			@timeout.zero?
		end

		def to_i
			@timeout
		end
	end

	class Tel < CustomerFwd
		attr_reader :timeout

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

		def to
			@tel
		end
	end

	class SIP < CustomerFwd
		attr_reader :timeout

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

		def to
			@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"
		end
	end

	URIS = {
		tel: Tel,
		sip: SIP,
		xmpp: XMPP
	}.freeze
end

M lib/customer_info.rb => lib/customer_info.rb +2 -9
@@ 15,24 15,17 @@ class CustomerInfo
	end

	def self.for(customer, plan, expires_at)
		fetch_inputs(customer, plan).then do |(auto_top_up_amount, registration)|
		plan.auto_top_up_amount.then do |auto_top_up_amount|
			new(
				plan: plan,
				auto_top_up_amount: auto_top_up_amount,
				tel: registration ? registration.phone : nil,
				tel: customer.registered? ? customer.regsitered?.phone : nil,
				balance: customer.balance,
				expires_at: expires_at
			)
		end
	end

	def self.fetch_inputs(customer, plan)
		EMPromise.all([
			plan.auto_top_up_amount,
			customer.registered?
		])
	end

	def account_status
		if plan.plan_name.nil?
			"Transitional"

M lib/customer_info_form.rb => lib/customer_info_form.rb +2 -1
@@ 1,11 1,12 @@
# frozen_string_literal: true

require_relative "bwmsgsv2_repo"
require_relative "customer_repo"
require_relative "proxied_jid"
require_relative "legacy_customer"

class CustomerInfoForm
	def initialize(customer_repo=CustomerRepo.new)
	def initialize(customer_repo=CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new))
		@customer_repo = customer_repo
	end


M lib/customer_repo.rb => lib/customer_repo.rb +19 -5
@@ 1,14 1,22 @@
# frozen_string_literal: true

require "lazy_object"

require_relative "customer"
require_relative "legacy_customer"
require_relative "polyfill"

class CustomerRepo
	def initialize(redis: REDIS, db: DB, braintree: BRAINTREE)
	def initialize(
		redis: LazyObject.new { REDIS },
		db: LazyObject.new { DB },
		braintree: LazyObject.new { BRAINTREE },
		sgx_repo: TrivialBackendSgxRepo.new
	)
		@redis = redis
		@db = db
		@braintree = braintree
		@sgx_repo = sgx_repo
	end

	def find(customer_id)


@@ 19,7 27,7 @@ class CustomerRepo
	end

	def find_by_jid(jid)
		if jid.to_s =~ /\Acustomer_(.+)@jmp.chat\Z/
		if jid.to_s =~ /\Acustomer_(.+)@#{CONFIG[:component][:jid]}\Z/
			find($1)
		else
			@redis.get("jmp_customer_id-#{jid}").then { |customer_id|


@@ 46,13 54,19 @@ class CustomerRepo
				"jmp_customer_id-#{jid}", cid, "jmp_customer_jid-#{cid}", jid
			).then do |redis_result|
				raise "Saving new customer to redis failed" unless redis_result == 1
				Customer.new(cid, Blather::JID.new(jid))
				Customer.new(cid, Blather::JID.new(jid), sgx: new_sgx(cid))
			end
		end
	end

protected

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

	def find_legacy_customer(jid)
		@redis.lindex("catapult_cred-#{jid}", 3).then do |tel|
			raise "No customer" unless tel


@@ 76,9 90,9 @@ protected
			FROM customer_plans LEFT JOIN balances USING (customer_id)
			WHERE customer_id=$1 LIMIT 1
		SQL
		result.then do |rows|
		EMPromise.all([@sgx_repo.get(customer_id), result]).then do |(sgx, rows)|
			data = hydrate_plan(customer_id, rows.first&.transform_keys(&:to_sym) || {})
			Customer.new(customer_id, Blather::JID.new(jid), **data)
			Customer.new(customer_id, Blather::JID.new(jid), sgx: sgx, **data)
		end
	end
end

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

class NotLoaded
	class NotLoadedError < StandardError; end

	def initialize(name)
		@name = name
	end

	def respond_to_missing?(*)
		true
	end

	def method_missing(*) # rubocop:disable Style/MethodMissing
		raise NotLoadedError, "#{@name} not loaded"
	end
end

M lib/registration.rb => lib/registration.rb +5 -7
@@ 13,13 13,11 @@ require_relative "./tel_selections"

class Registration
	def self.for(customer, tel_selections)
		customer.registered?.then do |registered|
			if registered
				Registered.new(registered.phone)
			else
				tel_selections[customer.jid].then(&:choose_tel).then do |tel|
					Activation.for(customer, tel)
				end
		if (reg = customer.registered?)
			Registered.new(reg.phone)
		else
			tel_selections[customer.jid].then(&:choose_tel).then do |tel|
				Activation.for(customer, tel)
			end
		end
	end

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

require_relative "backend_sgx"
require_relative "not_loaded"

class TrivialBackendSgxRepo
	def initialize(
		jid: CONFIG[:sgx],
		creds: CONFIG[:creds],
		component_jid: CONFIG[:component][:jid]
	)
		@jid = Blather::JID.new(jid)
		@creds = creds
		@component_jid = component_jid
	end

	def get(customer_id)
		BackendSgx.new(
			jid: @jid,
			creds: @creds,
			from_jid: Blather::JID.new("customer_#{customer_id}", @component_jid),
			ogm_url: NotLoaded.new(:ogm_url),
			fwd: NotLoaded.new(:fwd_timeout),
			transcription_enabled: NotLoaded.new(:transcription_enabled),
			registered?: NotLoaded.new(:registered?)
		)
	end
end

M sgx_jmp.rb => sgx_jmp.rb +20 -11
@@ 1,6 1,7 @@
# frozen_string_literal: true

require "pg/em/connection_pool"
require "bandwidth"
require "bigdecimal"
require "blather/client/dsl" # Require this first to not auto-include
require "blather/client"


@@ 68,6 69,7 @@ require_relative "lib/polyfill"
require_relative "lib/alt_top_up_form"
require_relative "lib/add_bitcoin_address"
require_relative "lib/backend_sgx"
require_relative "lib/bwmsgsv2_repo"
require_relative "lib/bandwidth_tn_order"
require_relative "lib/btc_sell_prices"
require_relative "lib/buy_account_credit_form"


@@ 100,6 102,10 @@ BandwidthIris::Client.global_options = {
	username: CONFIG[:creds][:username],
	password: CONFIG[:creds][:password]
}
BANDWIDTH_VOICE = Bandwidth::Client.new(
	voice_basic_auth_user_name: CONFIG[:creds][:username],
	voice_basic_auth_password: CONFIG[:creds][:password]
).voice_client.client

def new_sentry_hub(stanza, name: nil)
	hub = Sentry.get_current_hub&.new_from_top


@@ 200,7 206,7 @@ when_ready do
		self << ping
	end

	Web.run(LOG.child, CustomerRepo.new, *WEB_LISTEN)
	Web.run(LOG.child, *WEB_LISTEN)
end

# workqueue_count MUST be 0 or else Blather uses threads!


@@ 258,9 264,7 @@ message(
		&.find { |el| el["jid"].to_s.start_with?("customer_") }
	pass unless address

	CustomerRepo.new.find(
		Blather::JID.new(address["jid"].to_s).node.delete_prefix("customer_")
	).then { |customer|
	CustomerRepo.new.find_by_jid(address["jid"]).then { |customer|
		m.from = m.from.with(domain: CONFIG[:component][:jid])
		m.to = m.to.with(domain: customer.jid.domain)
		address["jid"] = customer.jid.to_s


@@ 362,7 366,9 @@ disco_items node: "http://jabber.org/protocol/commands" do |iq|
	reply = iq.reply
	reply.node = "http://jabber.org/protocol/commands"

	CustomerRepo.new.find_by_jid(iq.from.stripped).catch {
	CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find_by_jid(
		iq.from.stripped
	).catch {
		nil
	}.then { |customer|
		CommandList.for(customer)


@@ 397,7 403,8 @@ end
Command.new(
	"jabber:iq:register",
	"Register",
	list_for: ->(*) { true }
	list_for: ->(*) { true },
	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
) {
	Command.customer.catch {
		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Customer.create"))


@@ 542,7 549,8 @@ Command.new(
Command.new(
	"info",
	"Show Account Info",
	list_for: ->(*) { true }
	list_for: ->(*) { true },
	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
) {
	Command.customer.then(&:info).then do |info|
		Command.finish do |reply|


@@ 584,17 592,18 @@ Command.new(
Command.new(
	"migrate billing",
	"Switch from PayPal or expired trial to new billing",
	list_for: ->(tel:, customer:, **) { tel && !customer&.currency }
	list_for: ->(tel:, customer:, **) { tel && !customer&.currency },
	customer_repo: CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new)
) {
	EMPromise.all([
		Command.customer.then { |c| EMPromise.all([c, c.registered?.then(&:phone)]) },
		Command.customer,
		Command.reply do |reply|
			reply.allowed_actions = [:next]
			reply.command << FormTemplate.render("migrate_billing")
		end
	]).then do |((customer, tel), iq)|
	]).then do |(customer, iq)|
		Registration::Payment.for(
			iq, customer, tel,
			iq, customer, customer.registered?.phone,
			final_message: PaypalDone::MESSAGE,
			finish: PaypalDone
		).then(&:write).catch_only(Command::Execution::FinalStanza) do |s|

M test/test_backend_sgx.rb => test/test_backend_sgx.rb +11 -9
@@ 1,17 1,16 @@
# frozen_string_literal: true

require "test_helper"
require "bwmsgsv2_repo"
require "backend_sgx"
require "trivial_backend_sgx_repo"

BackendSgx::IQ_MANAGER = Minitest::Mock.new
Bwmsgsv2Repo::IQ_MANAGER = Minitest::Mock.new

class BackendSgxTest < Minitest::Test
	def setup
		@sgx = BackendSgx.new("test")
	end

	def test_registered
		BackendSgx::IQ_MANAGER.expect(
		Bwmsgsv2Repo::IQ_MANAGER.expect(
			:write,
			EMPromise.resolve(IBR.new.tap { |ibr| ibr.registered = true }),
			[Matching.new do |ibr|


@@ 19,12 18,13 @@ class BackendSgxTest < Minitest::Test
				assert_equal "customer_test@component", ibr.from.to_s
			end]
		)
		assert @sgx.registered?.sync
		sgx = Bwmsgsv2Repo.new(redis: FakeRedis.new).get("test").sync
		assert sgx.registered?
	end
	em :test_registered

	def test_registered_not_registered
		BackendSgx::IQ_MANAGER.expect(
		Bwmsgsv2Repo::IQ_MANAGER.expect(
			:write,
			EMPromise.resolve(IBR.new.tap { |ibr| ibr.registered = false }),
			[Matching.new do |ibr|


@@ 32,7 32,8 @@ class BackendSgxTest < Minitest::Test
				assert_equal "customer_test@component", ibr.from.to_s
			end]
		)
		refute @sgx.registered?.sync
		sgx = Bwmsgsv2Repo.new(redis: FakeRedis.new).get("test").sync
		refute sgx.registered?
	end
	em :test_registered_not_registered



@@ 48,7 49,8 @@ class BackendSgxTest < Minitest::Test
				assert_equal "+15555550000", ibr.phone
			end]
		)
		@sgx.register!("+15555550000")
		sgx = TrivialBackendSgxRepo.new.get("test")
		sgx.register!("+15555550000")
		BackendSgx::IQ_MANAGER.verify
	end
end

M test/test_command_list.rb => test/test_command_list.rb +5 -21
@@ 6,6 6,9 @@ require "command_list"

CommandList::Customer = Minitest::Mock.new
CommandList::REDIS = Minitest::Mock.new
CustomerRepo::REDIS = Minitest::Mock.new
CustomerRepo::DB = Minitest::Mock.new
CustomerRepo::BRAINTREE = Minitest::Mock.new

class CommandListTest < Minitest::Test
	SETUP = begin


@@ 44,11 47,6 @@ class CommandListTest < Minitest::Test
	em :test_for_unregistered

	def test_for_registered
		CommandList::REDIS.expect(
			:get,
			EMPromise.resolve(nil),
			["catapult_fwd-1"]
		)
		customer = OpenStruct.new(
			registered?: OpenStruct.new(phone: "1"),
			payment_methods: EMPromise.resolve([])


@@ 61,14 59,10 @@ class CommandListTest < Minitest::Test
	em :test_for_registered

	def test_for_registered_with_fwd
		CommandList::REDIS.expect(
			:get,
			EMPromise.resolve("tel:1"),
			["catapult_fwd-1"]
		)
		customer = OpenStruct.new(
			registered?: OpenStruct.new(phone: "1"),
			payment_methods: EMPromise.resolve([])
			payment_methods: EMPromise.resolve([]),
			fwd: OpenStruct.new
		)
		assert_equal(
			["no_customer", "registered", "fwd"],


@@ 78,11 72,6 @@ class CommandListTest < Minitest::Test
	em :test_for_registered_with_fwd

	def test_for_registered_with_credit_card
		CommandList::REDIS.expect(
			:get,
			EMPromise.resolve(nil),
			["catapult_fwd-1"]
		)
		customer = OpenStruct.new(
			registered?: OpenStruct.new(phone: "1"),
			plan_name: "test",


@@ 96,11 85,6 @@ class CommandListTest < Minitest::Test
	em :test_for_registered_with_credit_card

	def test_for_registered_with_currency
		CommandList::REDIS.expect(
			:get,
			EMPromise.resolve(nil),
			["catapult_fwd-1"]
		)
		customer = OpenStruct.new(
			registered?: OpenStruct.new(phone: "1"),
			currency: :USD

M test/test_customer_info.rb => test/test_customer_info.rb +4 -4
@@ 9,7 9,7 @@ CustomerPlan::REDIS = Minitest::Mock.new
class CustomerInfoTest < Minitest::Test
	def test_info_does_not_crash
		sgx = Minitest::Mock.new
		sgx.expect(:registered?, EMPromise.resolve(nil))
		sgx.expect(:registered?, false)

		CustomerPlan::REDIS.expect(
			:get,


@@ 25,7 25,7 @@ class CustomerInfoTest < Minitest::Test

	def test_admin_info_does_not_crash
		sgx = Minitest::Mock.new
		sgx.expect(:registered?, EMPromise.resolve(nil))
		sgx.expect(:registered?, false)

		CustomerPlan::REDIS.expect(
			:get,


@@ 41,7 41,7 @@ class CustomerInfoTest < Minitest::Test

	def test_inactive_info_does_not_crash
		sgx = Minitest::Mock.new
		sgx.expect(:registered?, EMPromise.resolve(nil))
		sgx.expect(:registered?, false)

		CustomerPlan::REDIS.expect(
			:get,


@@ 63,7 63,7 @@ class CustomerInfoTest < Minitest::Test

	def test_inactive_admin_info_does_not_crash
		sgx = Minitest::Mock.new
		sgx.expect(:registered?, EMPromise.resolve(nil))
		sgx.expect(:registered?, false)

		CustomerPlan::REDIS.expect(
			:get,

M test/test_customer_repo.rb => test/test_customer_repo.rb +5 -5
@@ 8,15 8,15 @@ class CustomerRepoTest < Minitest::Test
		# sgx-jmp customer
		"jmp_customer_jid-test" => "test@example.com",
		"jmp_customer_id-test@example.com" => "test",
		"catapult_jid-+13334445555" => "customer_test@jmp.chat",
		"catapult_cred-customer_test@jmp.chat" => [
		"catapult_jid-+13334445555" => "customer_test@component",
		"catapult_cred-customer_test@component" => [
			"test_bw_customer", "", "", "+13334445555"
		],
		# sgx-jmp customer, empty DB
		"jmp_customer_jid-empty" => "empty@example.com",
		"jmp_customer_id-empty@example.com" => "empty",
		"catapult_jid-+16667778888" => "customer_empty@jmp.chat",
		"catapult_cred-customer_empty@jmp.chat" => [
		"catapult_jid-+16667778888" => "customer_empty@component",
		"catapult_cred-customer_empty@component" => [
			"test_bw_customer", "", "", "+16667778888"
		],
		# v2 customer


@@ 75,7 75,7 @@ class CustomerRepoTest < Minitest::Test
	em :test_find_by_id

	def test_find_by_customer_jid
		customer = @repo.find_by_jid("customer_test@jmp.chat").sync
		customer = @repo.find_by_jid("customer_test@component").sync
		assert_kind_of Customer, customer
		assert_equal 1234, customer.balance
		assert_equal "merchant_usd", customer.merchant_account

M test/test_helper.rb => test/test_helper.rb +8 -0
@@ 168,10 168,18 @@ class FakeRedis
		set(key, value)
	end

	def mget(*keys)
		EMPromise.all(keys.map(&method(:get)))
	end

	def get(key)
		EMPromise.resolve(@values[key])
	end

	def getbit(key, bit)
		get(key).then { |v| v.to_i.to_s(2)[bit].to_i }
	end

	def exists(*keys)
		EMPromise.resolve(
			@values.select { |k, _| keys.include? k }.size

M test/test_registration.rb => test/test_registration.rb +4 -4
@@ 20,7 20,7 @@ end
class RegistrationTest < Minitest::Test
	def test_for_registered
		sgx = OpenStruct.new(
			registered?: EMPromise.resolve(OpenStruct.new(phone: "+15555550000"))
			registered?: OpenStruct.new(phone: "+15555550000")
		)
		iq = Blather::Stanza::Iq::Command.new
		iq.from = "test@example.com"


@@ 38,7 38,7 @@ class RegistrationTest < Minitest::Test
		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))
			sgx = OpenStruct.new(registered?: false)
			Registration.for(
				customer(
					plan_name: "test_usd",


@@ 53,7 53,7 @@ class RegistrationTest < Minitest::Test
	em :test_for_activated

	def test_for_not_activated_with_customer_id
		sgx = OpenStruct.new(registered?: EMPromise.resolve(nil))
		sgx = OpenStruct.new(registered?: false)
		web_manager = TelSelections.new(redis: FakeRedis.new)
		web_manager.set("test@example.net", "+15555550000")
		iq = Blather::Stanza::Iq::Command.new


@@ 520,7 520,7 @@ class RegistrationTest < Minitest::Test
		BackendSgx::REDIS = Minitest::Mock.new

		def setup
			@sgx = Minitest::Mock.new(BackendSgx.new("test"))
			@sgx = Minitest::Mock.new(TrivialBackendSgxRepo.new.get("test"))
			iq = Blather::Stanza::Iq::Command.new
			iq.from = "test\\40example.com@cheogram.com"
			@finish = Registration::Finish.new(

M web.rb => web.rb +30 -125
@@ 5,99 5,12 @@ require "forwardable"
require "roda"
require "thin"
require "sentry-ruby"
require "bandwidth"

Faraday.default_adapter = :em_synchrony

require_relative "lib/cdr"
require_relative "lib/roda_capture"
require_relative "lib/roda_em_promise"
require_relative "lib/rack_fiber"

BANDWIDTH_VOICE = Bandwidth::Client.new(
	voice_basic_auth_user_name: CONFIG[:creds][:username],
	voice_basic_auth_password: CONFIG[:creds][:password]
).voice_client.client

module CustomerFwd
	def self.from_redis(redis, customer, tel)
		EMPromise.all([
			redis.get("catapult_fwd-#{tel}"),
			customer.fwd_timeout
		]).then do |(fwd, stimeout)|
			timeout = Timeout.new(stimeout)
			next if !fwd || timeout.zero?
			self.for(fwd, timeout)
		end
	end

	def self.for(uri, timeout)
		case uri
		when /^tel:/
			Tel.new(uri, timeout)
		when /^sip:/
			SIP.new(uri, timeout)
		when /^xmpp:/
			XMPP.new(uri, timeout)
		else
			raise "Unknown forward URI: #{uri}"
		end
	end

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

		def zero?
			@timeout.zero?
		end

		def to_i
			@timeout
		end
	end

	class Tel
		attr_reader :timeout

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

		def to
			@tel
		end
	end

	class SIP
		attr_reader :timeout

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

		def to
			@uri
		end
	end

	class XMPP
		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"
		end
	end
end

# rubocop:disable Metrics/ClassLength
class Web < Roda
	use Rack::Fiber # Must go first!


@@ 112,9 25,8 @@ class Web < Roda
		attr_reader :customer_repo, :log
		attr_reader :true_inbound_call, :outbound_transfers

		def run(log, customer_repo, *listen_on)
		def run(log, *listen_on)
			plugin :common_logger, log, method: :info
			@customer_repo = customer_repo
			@true_inbound_call = {}
			@outbound_transfers = {}
			Thin::Logging.logger = log


@@ 127,8 39,7 @@ class Web < Roda
	end

	extend Forwardable
	def_delegators :'self.class', :customer_repo, :true_inbound_call,
	               :outbound_transfers
	def_delegators :'self.class', :true_inbound_call, :outbound_transfers
	def_delegators :request, :params

	def log


@@ 221,7 132,7 @@ class Web < Roda
								end
							end

							customer_repo.find_by_tel(params["to"]).then do |customer|
							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
								CDR.for_inbound(customer.customer_id, params).save
							end
						}.catch(&method(:log_error))


@@ 257,7 168,7 @@ class Web < Roda
								"https://jmp.chat"
							)

							customer_repo.find_by_tel(params["to"]).then do |customer|
							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
								m = Blather::Stanza::Message.new
								m.chat_state = nil
								m.from = from_jid


@@ 271,7 182,7 @@ class Web < Roda
						end

						r.post "transcription" do
							customer_repo.find_by_tel(params["to"]).then do |customer|
							CustomerRepo.new.find_by_tel(params["to"]).then do |customer|
								m = Blather::Stanza::Message.new
								m.chat_state = nil
								m.from = from_jid


@@ 286,19 197,13 @@ class Web < Roda
						end

						r.post do
							customer_repo
							CustomerRepo
								.new(sgx_repo: Bwmsgsv2Repo.new)
								.find_by_tel(params["to"])
								.then { |customer|
									EMPromise.all([
										customer.ogm(params["from"]),
										customer.catapult_flag(
											BackendSgx::VOICEMAIL_TRANSCRIPTION_DISABLED
										)
									])
								}.then do |(ogm, transcription_disabled)|
								.then do |customer|
									render :voicemail, locals: {
										ogm: ogm,
										transcription_enabled: !transcription_disabled
										ogm: customer.ogm(params["from"]),
										transcription_enabled: customer.transcription_enabled
									}
								end
						end


@@ 316,25 221,25 @@ class Web < Roda
						return render :pause, locals: { duration: 300 }
					end

					customer_repo.find_by_tel(params["to"]).then do |customer|
						CustomerFwd.from_redis(::REDIS, customer, params["to"]).then do |fwd|
							if fwd
								body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
									cc.to = fwd.to
									cc.from = params["from"]
									cc.application_id = params["applicationId"]
									cc.call_timeout = fwd.timeout.to_i
									cc.answer_url = url inbound_calls_path(nil)
									cc.disconnect_url = url inbound_calls_path(:transfer_complete)
								end
								true_inbound_call[pseudo_call_id] = params["callId"]
								outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
									CONFIG[:creds][:account], body: body
								).data.call_id
								render :pause, locals: { duration: 300 }
							else
								render :redirect, locals: { to: inbound_calls_path(:voicemail) }
					CustomerRepo.new(
						sgx_repo: Bwmsgsv2Repo.new
					).find_by_tel(params["to"]).then(&:fwd).then do |fwd|
						if fwd
							body = Bandwidth::ApiCreateCallRequest.new.tap do |cc|
								cc.to = fwd.to
								cc.from = params["from"]
								cc.application_id = params["applicationId"]
								cc.call_timeout = fwd.timeout.to_i
								cc.answer_url = url inbound_calls_path(nil)
								cc.disconnect_url = url inbound_calls_path(:transfer_complete)
							end
							true_inbound_call[pseudo_call_id] = params["callId"]
							outbound_transfers[pseudo_call_id] = BANDWIDTH_VOICE.create_call(
								CONFIG[:creds][:account], body: body
							).data.call_id
							render :pause, locals: { duration: 300 }
						else
							render :redirect, locals: { to: inbound_calls_path(:voicemail) }
						end
					end
				end


@@ 353,9 258,9 @@ class Web < Roda

				r.post do
					customer_id = params["from"].sub(/^\+1/, "")
					customer_repo.find(customer_id).then(:registered?).then do |reg|
					CustomerRepo.new(sgx_repo: Bwmsgsv2Repo.new).find(customer_id).then do |c|
						render :forward, locals: {
							from: reg.phone,
							from: c.registered?.phone,
							to: params["to"]
						}
					end