sgx_jmp.rb

  1# frozen_string_literal: true
  2
  3require "pg/em"
  4require "bigdecimal"
  5require "blather/client/dsl" # Require this first to not auto-include
  6require "blather/client"
  7require "braintree"
  8require "dhall"
  9require "em-hiredis"
 10require "em_promise"
 11require "ruby-bandwidth-iris"
 12
 13CONFIG =
 14	Dhall::Coder
 15	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
 16	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
 17
 18singleton_class.class_eval do
 19	include Blather::DSL
 20	Blather::DSL.append_features(self)
 21end
 22
 23require_relative "lib/backend_sgx"
 24require_relative "lib/bandwidth_tn_order"
 25require_relative "lib/btc_sell_prices"
 26require_relative "lib/buy_account_credit_form"
 27require_relative "lib/customer"
 28require_relative "lib/electrum"
 29require_relative "lib/em"
 30require_relative "lib/payment_methods"
 31require_relative "lib/registration"
 32require_relative "lib/transaction"
 33require_relative "lib/web_register_manager"
 34
 35ELECTRUM = Electrum.new(**CONFIG[:electrum])
 36
 37Faraday.default_adapter = :em_synchrony
 38BandwidthIris::Client.global_options = {
 39	account_id: CONFIG[:creds][:account],
 40	username: CONFIG[:creds][:username],
 41	password: CONFIG[:creds][:password]
 42}
 43
 44# Braintree is not async, so wrap in EM.defer for now
 45class AsyncBraintree
 46	def initialize(environment:, merchant_id:, public_key:, private_key:, **)
 47		@gateway = Braintree::Gateway.new(
 48			environment: environment,
 49			merchant_id: merchant_id,
 50			public_key: public_key,
 51			private_key: private_key
 52		)
 53	end
 54
 55	def respond_to_missing?(m, *)
 56		@gateway.respond_to?(m)
 57	end
 58
 59	def method_missing(m, *args)
 60		return super unless respond_to_missing?(m, *args)
 61
 62		EM.promise_defer(klass: PromiseChain) do
 63			@gateway.public_send(m, *args)
 64		end
 65	end
 66
 67	class PromiseChain < EMPromise
 68		def respond_to_missing?(*)
 69			false # We don't actually know what we respond to...
 70		end
 71
 72		def method_missing(m, *args)
 73			return super if respond_to_missing?(m, *args)
 74			self.then { |o| o.public_send(m, *args) }
 75		end
 76	end
 77end
 78
 79BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
 80
 81def panic(e)
 82	m = e.respond_to?(:message) ? e.message : e
 83	warn "Error raised during event loop: #{e.class}: #{m}"
 84	warn e.backtrace if e.respond_to?(:backtrace)
 85	exit 1
 86end
 87
 88EM.error_handler(&method(:panic))
 89
 90when_ready do
 91	BLATHER = self
 92	REDIS = EM::Hiredis.connect
 93	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
 94	DB = PG::EM::Client.new(dbname: "jmp")
 95	DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 96	DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 97
 98	EM.add_periodic_timer(3600) do
 99		ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
100		ping.from = CONFIG[:component][:jid]
101		self << ping
102	end
103end
104
105# workqueue_count MUST be 0 or else Blather uses threads!
106setup(
107	CONFIG[:component][:jid],
108	CONFIG[:component][:secret],
109	CONFIG[:server][:host],
110	CONFIG[:server][:port],
111	nil,
112	nil,
113	workqueue_count: 0
114)
115
116message to: /\Acustomer_/, from: /@#{CONFIG[:sgx]}(\/|\Z)/ do |m|
117	Customer.for_customer_id(
118		m.to.node.delete_prefix("customer_")
119	).then { |customer| customer.stanza_to(m) }.catch(&method(:panic))
120end
121
122message do |m|
123	Customer.for_jid(m.from.stripped).then { |customer|
124		customer.stanza_from(m)
125	}.catch(&method(:panic))
126end
127
128message :error? do |m|
129	puts "MESSAGE ERROR: #{m.inspect}"
130end
131
132class SessionManager
133	def initialize(blather, id_msg, timeout: 5)
134		@blather = blather
135		@sessions = {}
136		@id_msg = id_msg
137		@timeout = timeout
138	end
139
140	def promise_for(stanza)
141		id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}"
142		@sessions.fetch(id) do
143			@sessions[id] = EMPromise.new
144			EM.add_timer(@timeout) do
145				@sessions.delete(id)&.reject(:timeout)
146			end
147			@sessions[id]
148		end
149	end
150
151	def write(stanza)
152		promise = promise_for(stanza)
153		@blather << stanza
154		promise
155	end
156
157	def fulfill(stanza)
158		id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
159		if stanza.error?
160			@sessions.delete(id)&.reject(stanza)
161		else
162			@sessions.delete(id)&.fulfill(stanza)
163		end
164	end
165end
166
167IQ_MANAGER = SessionManager.new(self, :id)
168COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
169web_register_manager = WebRegisterManager.new
170
171disco_items node: "http://jabber.org/protocol/commands" do |iq|
172	reply = iq.reply
173	reply.items = [
174		# TODO: don't show this item if no braintree methods available
175		# TODO: don't show this item if no plan for this customer
176		Blather::Stanza::DiscoItems::Item.new(
177			iq.to,
178			"buy-credit",
179			"Buy account credit"
180		),
181		Blather::Stanza::DiscoItems::Item.new(
182			iq.to,
183			"jabber:iq:register",
184			"Register"
185		)
186	]
187	self << reply
188end
189
190command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
191	Customer.for_jid(iq.from.stripped).catch {
192		Customer.create(iq.from.stripped)
193	}.then { |customer|
194		Registration.for(
195			iq,
196			customer,
197			web_register_manager
198		).then(&:write)
199	}.catch(&method(:panic))
200end
201
202def reply_with_note(iq, text, type: :info)
203	reply = iq.reply
204	reply.status = :completed
205	reply.note_type = type
206	reply.note_text = text
207
208	self << reply
209end
210
211command :execute?, node: "buy-credit", sessionid: nil do |iq|
212	reply = iq.reply
213	reply.allowed_actions = [:complete]
214
215	Customer.for_jid(iq.from.stripped).then { |customer|
216		BuyAccountCreditForm.new(customer).add_to_form(reply.form).then { customer }
217	}.then { |customer|
218		EMPromise.all([
219			customer,
220			customer.payment_methods,
221			COMMAND_MANAGER.write(reply)
222		])
223	}.then { |(customer, payment_methods, iq2)|
224		iq = iq2 # This allows the catch to use it also
225		payment_method = payment_methods.fetch(
226			iq.form.field("payment_method")&.value.to_i
227		)
228		amount = iq.form.field("amount").value.to_s
229		Transaction.sale(customer, amount, payment_method)
230	}.then { |transaction|
231		transaction.insert.then { transaction.amount }
232	}.then { |amount|
233		reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.")
234	}.catch { |e|
235		text = "Failed to buy credit, system said: #{e.message}"
236		reply_with_note(iq, text, type: :error)
237	}.catch(&method(:panic))
238end
239
240command sessionid: /./ do |iq|
241	COMMAND_MANAGER.fulfill(iq)
242end
243
244iq :result? do |iq|
245	IQ_MANAGER.fulfill(iq)
246end
247
248iq :error? do |iq|
249	IQ_MANAGER.fulfill(iq)
250end