~singpolyma/sgx-jmp

6f5e29940a1936e9268bc793b8aea87b68e18378 — Stephen Paul Weber 5 months ago d799a92 command-object
WIP
4 files changed, 258 insertions(+), 184 deletions(-)

M Gemfile
A lib/command.rb
M lib/command_list.rb
M sgx_jmp.rb
M Gemfile => Gemfile +2 -2
@@ 10,12 10,12 @@ gem "em-hiredis"
gem "em-http-request"
gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
gem "em-synchrony"
gem "em_promise.rb", "~> 0.0.2"
gem "em_promise.rb", "~> 0.0.3"
gem "eventmachine"
gem "money-open-exchange-rates"
gem "ougai"
gem "ruby-bandwidth-iris"
gem "sentry-ruby"
gem "sentry-ruby", "<= 4.3.1"
gem "statsd-instrument", git: "https://github.com/singpolyma/statsd-instrument.git", branch: "graphite"
gem "value_semantics", git: "https://github.com/singpolyma/value_semantics"


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

require_relative "customer_repo"

class Command
	def self.exection
		Thread.current[:execution]
	end

	def self.reply(stanza)
		execution.reply(stanza)
	end

	def self.finish(*args, **kwargs, &blk)
		execution.finish(*args, **kwargs, &blk)
	end

	def self.customer
		execution.customer
	end

	def self.log
		execution.log
	end

	class Execution
		attr_reader :customer_repo, :log, :iq

		def initialize(customer_repo, blather, format_error, iq)
			@customer_repo = customer_repo
			@blather = blather
			@format_error = format_error
			@iq = iq
			@log = LOG.child(node: iq.node)
		end

		def execute
			StatsD.increment("command", tags: ["node:#{iq.node}"])
			EMPromise.resolve(nil).then do
				Thread.current[:execution] = self
				setup_sentry_hub!
				catch_after(@blk.call(self))
			end
		end

		def reply(stanza=nil)
			stanza ||= iq.reply.tap do |reply|
				reply.status = :executing
			end
			stanza = yield stanza if block_given?
			COMMAND_MANAGER.write(stanza).then do |new_iq|
				@iq = new_iq
			end
		end

		def finish(text=nil, type: :info, status: :completed)
			reply = @iq.reply
			reply.status = status
			yield reply if block_given?
			if text
				reply.note_type = type
				reply.note_text = text
			end
			@blather << reply
			@iq = nil
		end

		def sentry_hub
			return @sentry_hub if @sentry_hub

			# Stored on Fiber-local in 4.3.1 and earlier
			# https://github.com/getsentry/sentry-ruby/issues/1495
			@sentry_hub = Sentry.get_current_hub
			raise "Sentry.init has not been called" unless @sentry_hub

			@sentry_hub.push_scope
			@sentry_hub.current_scope.clear_breadcrumbs
			@sentry_hub.current_scope.set_transaction_name(@iq.node)
			@sentry_hub.current_scope.set_user(jid: @iq.from.stripped.to_s)
			@sentry_hub
		end

		def customer
			@customer ||= @customer_repo.find_by_jid(@iq.from.stripped).then do |c|
				sentry_hub.current_scope.set_user(
					id: c.customer_id,
					jid: @iq.from.stripped
				)
				c
			end
		end

	protected

		def catch_after(promise)
			promise.catch_only(ErrorToSend) { |e|
				@blather << e.stanza
			}.catch { |e|
				log_error(e)
				finish(format_error(e), type: :error)
			}.catch { |e| panic(e) }
		end

		def log_error(e)
			@log.error(
				"Error raised during #{iq.node}: #{e.class}",
				e
			)
			if e.is_a?(::Exception)
				sentry_hub.capture_exception(e)
			else
				sentry_hub.capture_message(e.to_s)
			end
		end
	end

	attr_reader :node, :name

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

	def register(blather, command_list)
		command_list.register(self)
		blather.command(:execute?, node: @node, sessionid: nil) do |iq|
			Execution.new(@customer_repo, blather, @format_error, iq).execute
		end
	end

	def list_for?(**kwargs)
		@list_for.call(**kwargs)
	end
end

M lib/command_list.rb => lib/command_list.rb +22 -51
@@ 3,65 3,36 @@
class CommandList
	include Enumerable

	def self.for(customer)
		EMPromise.resolve(customer&.registered?).catch { nil }.then do |reg|
			next Registered.for(customer, reg.phone) if reg
			CommandList.new
		end
	def self.register(command)
		@commands ||= []
		@commands << command
	end

	def each
		yield node: "jabber:iq:register", name: "Register"
	end

	class Registered < CommandList
		def self.for(customer, tel)
			EMPromise.all([
				REDIS.get("catapult_fwd-#{tel}"),
				customer.plan_name ? customer.payment_methods : []
			]).then do |(fwd, payment_methods)|
				Registered.new(*[
					(HAS_CREDIT_CARD unless payment_methods.empty?),
					(HAS_CURRENCY if customer.currency),
					(HAS_FORWARDING if fwd)
				].compact)
	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
		end
	end

		def initialize(*args)
			@extra = args
		end

		ALWAYS = [
			{ node: "number-display", name: "Display JMP Number" },
			{ node: "configure-calls", name: "Configure Calls" },
			{ node: "usage", name: "Show Monthly Usage" },
			{ node: "reset sip account", name: "Create or Reset SIP Account" },
			{
				node: "credit cards",
				name: "Credit Card Settings and Management"
			}
		].freeze
	def self.args_for(customer, reg)
		args = { customer: customer, tel: reg ? reg.phone : nil }
		return EMPromise.resolve(args) unless phone

		def each
			super
			([ALWAYS] + @extra).each do |commands|
				commands.each { |x| yield x }
			end
		EMPromise.all([
			REDIS.get("catapult_fwd-#{tel}"),
			customer.plan_name ? customer.payment_methods : []
		]).then do |(fwd, payment_methods)|
			args.merge(fwd: fwd, payment_methods: payment_methods)
		end
	end

	HAS_CURRENCY = [
		node: "alt top up",
		name: "Buy Account Credit by Bitcoin, Mail, or Interac eTransfer"
	].freeze

	HAS_FORWARDING = [
		node: "record-voicemail-greeting",
		name: "Record Voicemail Greeting"
	].freeze
	def initialize(commands)
		@commands = commands
	end

	HAS_CREDIT_CARD = [
		node: "top up", name: "Buy Account Credit by Credit Card"
	].freeze
	def each(&blk)
		commands.map { |c| { node: c.node, name: c.name } }.each(&blk)
	end
end

M sgx_jmp.rb => sgx_jmp.rb +90 -131
@@ 42,6 42,7 @@ require_relative "lib/backend_sgx"
require_relative "lib/bandwidth_tn_order"
require_relative "lib/btc_sell_prices"
require_relative "lib/buy_account_credit_form"
require_relative "lib/command"
require_relative "lib/command_list"
require_relative "lib/customer"
require_relative "lib/customer_repo"


@@ 113,7 114,10 @@ end
BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])

def panic(e, hub=nil)
	LOG.fatal "Error raised during event loop: #{e.class}", e
	(Thread.current[:log] || LOG).fatal(
		"Error raised during event loop: #{e.class}",
		e
	)
	if e.is_a?(::Exception)
		(hub || Sentry).capture_exception(e, hint: { background: false })
	else


@@ 370,44 374,26 @@ iq "/iq/ns:services", ns: "urn:xmpp:extdisco:2" do |iq|
	self << reply
end

command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])

	sentry_hub = new_sentry_hub(iq, name: iq.node)
	EMPromise.resolve(nil).then {
		CustomerRepo.new.find_by_jid(iq.from.stripped)
	}.catch {
		sentry_hub.add_breadcrumb(Sentry::Breadcrumb.new(
			message: "Customer.create"
		))
		CustomerRepo.new.create(iq.from.stripped)
Command.new(
	"jabber:iq:register",
	"Register",
	list_for: ->(*) { true }
) {
	Command.customer.catch {
		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Customer.create"))
		Command.execution.customer_repo.create(Command.execution.iq.from.stripped)
	}.then { |customer|
		sentry_hub.current_scope.set_user(
			id: customer.customer_id,
			jid: iq.from.stripped.to_s
		)
		sentry_hub.add_breadcrumb(Sentry::Breadcrumb.new(
			message: "Registration.for"
		))
		Sentry.add_breadcrumb(Sentry::Breadcrumb.new(message: "Registration.for"))
		# TODO: Have to change all of lib/registration to use Command.reply and Command.finish
		# Can also use Command.log and Sentry.* wherever you like
		Registration.for(
			iq,
			customer,
			web_register_manager
		).then(&:write).then { StatsD.increment("registration.completed") }
	}.catch_only(ErrorToSend) { |e|
		self << e.stanza
	}.catch { |e| panic(e, sentry_hub) }
end

def reply_with_note(iq, text, type: :info)
	reply = iq.reply
	reply.status = :completed
	reply.note_type = type
	reply.note_text = text

	self << reply
end
		).then(&:write)
	}.then { StatsD.increment("registration.completed") }
}.register(self, CommandList)

# TODO: one Command each or a allow Command to have multiple nodes or just leave it?
# Commands that just pass through to the SGX
command node: [
	"number-display",


@@ 427,117 413,90 @@ command node: [
	}.catch { |e| panic(e, sentry_hub) }
end

command :execute?, node: "credit cards", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])

	sentry_hub = new_sentry_hub(iq, name: iq.node)
	reply = iq.reply
	reply.status = :completed

	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
		oob = OOB.find_or_create(reply.command)
		oob.url = CONFIG[:credit_card_url].call(
Command.new(
	"credit cards",
	"Credit Card Settings and Management"
) {
	Command.customer.then do |customer|
		url = CONFIG[:credit_card_url].call(
			reply.to.stripped.to_s.gsub("\\", "%5C"),
			customer.customer_id
		)
		oob.desc = "Manage credits cards and settings"

		reply.note_type = :info
		reply.note_text = "#{oob.desc}: #{oob.url}"

		self << reply
	}.catch { |e| panic(e, sentry_hub) }
end

command :execute?, node: "top up", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])

	sentry_hub = new_sentry_hub(iq, name: iq.node)
	reply = iq.reply
	reply.allowed_actions = [:complete]

	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
		desc = "Manage credits cards and settings"
		Command.finish("#{desc}: #{url}") do |reply|
			oob = OOB.find_or_create(reply.command)
			oob.url = url
			oob.desc = desc
		end
	end
}.register(self, CommandList)

Command.new(
	"top up",
	"Buy Account Credit by Credit Card",
	list_for: ->(payment_methods:, **) { !payment_methods.empty? },
	format_error: ->(e) { "Failed to buy credit, system said: #{e.message}" }
) {
	Command.customer.then { |customer|
		BuyAccountCreditForm.for(customer).then do |credit_form|
			credit_form.add_to_form(reply.form)
			COMMAND_MANAGER.write(reply).then { |iq2| [customer, credit_form, iq2] }
			Command.reply { |reply|
				reply.allowed_actions = [:complete]
				credit_form.add_to_form(reply.form)
			}.then do |iq|
				Transaction.sale(customer, **credit_form.parse(iq.form))
			end
		end
	}.then { |(customer, credit_form, iq2)|
		iq = iq2 # This allows the catch to use it also
		Transaction.sale(customer, **credit_form.parse(iq2.form))
	}.then { |transaction|
		transaction.insert.then do
			reply_with_note(iq, "#{transaction} added to your account balance.")
			Command.finish("#{transaction} added to your account balance.")
		end
	}.catch_only(BuyAccountCreditForm::AmountValidationError) { |e|
		reply_with_note(iq, e.message, type: :error)
	}.catch { |e|
		sentry_hub.capture_exception(e)
		text = "Failed to buy credit, system said: #{e.message}"
		reply_with_note(iq, text, type: :error)
	}.catch { |e| panic(e, sentry_hub) }
end

command :execute?, node: "alt top up", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])

	sentry_hub = new_sentry_hub(iq, name: iq.node)
	reply = iq.reply
	reply.status = :executing
	reply.allowed_actions = [:complete]

	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
		sentry_hub.current_scope.set_user(
			id: customer.customer_id,
			jid: iq.from.stripped.to_s
		)

	}.catch_only(BuyAccountCreditForm::AmountValidationError) do |e|
		Command.finish(e.message, type: :error)
	end
}.register(self, CommandList)

Command.new(
	"alt top up",
	"Buy Account Credit by Bitcoin, Mail, or Interac eTransfer",
	list_for: ->(customer:, **) { !!customer.currency }
) {
	Command.customer.then { |customer|
		EMPromise.all([AltTopUpForm.for(customer), customer])
	}.then { |(alt_form, customer)|
		reply.command << alt_form.form

		COMMAND_MANAGER.write(reply).then do |iq2|
			AddBitcoinAddress.for(iq2, alt_form, customer).write
	}.then do |(alt_form, customer)|
		Command.reply { |reply|
			reply.allowed_actions = [:complete]
			reply.command << alt_form.form
		}.then do |iq|
			AddBitcoinAddress.for(iq, alt_form, customer).write
		end
	}.catch { |e| panic(e, sentry_hub) }
end

command :execute?, node: "reset sip account", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])

	sentry_hub = new_sentry_hub(iq, name: iq.node)
	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
		sentry_hub.current_scope.set_user(
			id: customer.customer_id,
			jid: iq.from.stripped.to_s
		)
		customer.reset_sip_account
	}.then { |sip_account|
		reply = iq.reply
		reply.command << sip_account.form
		BLATHER << reply
	}.catch { |e| panic(e, sentry_hub) }
end

command :execute?, node: "usage", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])
	end
}.register(self, CommandList)

Command.new(
	"reset sip account",
	"Create or Reset SIP Account"
) {
	Command.customer.then(&:reset_sip_account).then do |sip_account|
		Command.finish do |reply|
			reply.command << sip_account.form
		end
	end
}.register(self, CommandList)

	sentry_hub = new_sentry_hub(iq, name: iq.node)
Command.new(
	"usage",
	"Show Monthly Usage"
) {
	report_for = (Date.today..(Date.today << 1))

	CustomerRepo.new.find_by_jid(iq.from.stripped).then { |customer|
		sentry_hub.current_scope.set_user(
			id: customer.customer_id,
			jid: iq.from.stripped.to_s
		)

	Command.customer.then { |customer|
		customer.usage_report(report_for)
	}.then { |usage_report|
		reply = iq.reply
		reply.status = :completed
		reply.command << usage_report.form
		BLATHER << reply
	}.catch { |e| panic(e, sentry_hub) }
end
	}.then do |usage_report|
		Command.finish do |reply|
			reply.command << usage_report.form
		end
	end
}.register(self, CommandList)

command :execute?, node: "web-register", sessionid: nil do |iq|
	StatsD.increment("command", tags: ["node:#{iq.node}"])