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 "date"
  9require "dhall"
 10require "em-hiredis"
 11require "em_promise"
 12require "ruby-bandwidth-iris"
 13
 14CONFIG =
 15	Dhall::Coder
 16	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
 17	.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
 18
 19singleton_class.class_eval do
 20	include Blather::DSL
 21	Blather::DSL.append_features(self)
 22end
 23
 24require_relative "lib/backend_sgx"
 25require_relative "lib/bandwidth_tn_order"
 26require_relative "lib/btc_sell_prices"
 27require_relative "lib/buy_account_credit_form"
 28require_relative "lib/customer"
 29require_relative "lib/electrum"
 30require_relative "lib/em"
 31require_relative "lib/payment_methods"
 32require_relative "lib/registration"
 33require_relative "lib/transaction"
 34require_relative "lib/web_register_manager"
 35
 36ELECTRUM = Electrum.new(**CONFIG[:electrum])
 37
 38Faraday.default_adapter = :em_synchrony
 39BandwidthIris::Client.global_options = {
 40	account_id: CONFIG[:creds][:account],
 41	username: CONFIG[:creds][:username],
 42	password: CONFIG[:creds][:password]
 43}
 44
 45# Braintree is not async, so wrap in EM.defer for now
 46class AsyncBraintree
 47	def initialize(environment:, merchant_id:, public_key:, private_key:, **)
 48		@gateway = Braintree::Gateway.new(
 49			environment: environment,
 50			merchant_id: merchant_id,
 51			public_key: public_key,
 52			private_key: private_key
 53		)
 54	end
 55
 56	def respond_to_missing?(m, *)
 57		@gateway.respond_to?(m)
 58	end
 59
 60	def method_missing(m, *args)
 61		return super unless respond_to_missing?(m, *args)
 62
 63		EM.promise_defer(klass: PromiseChain) do
 64			@gateway.public_send(m, *args)
 65		end
 66	end
 67
 68	class PromiseChain < EMPromise
 69		def respond_to_missing?(*)
 70			false # We don't actually know what we respond to...
 71		end
 72
 73		def method_missing(m, *args)
 74			return super if respond_to_missing?(m, *args)
 75			self.then { |o| o.public_send(m, *args) }
 76		end
 77	end
 78end
 79
 80BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
 81
 82def panic(e)
 83	m = e.respond_to?(:message) ? e.message : e
 84	warn "Error raised during event loop: #{e.class}: #{m}"
 85	warn e.backtrace if e.respond_to?(:backtrace)
 86	exit 1
 87end
 88
 89EM.error_handler(&method(:panic))
 90
 91when_ready do
 92	BLATHER = self
 93	REDIS = EM::Hiredis.connect
 94	BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
 95	DB = PG::EM::Client.new(dbname: "jmp")
 96	DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
 97	DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 98
 99	EM.add_periodic_timer(3600) do
100		ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
101		ping.from = CONFIG[:component][:jid]
102		self << ping
103	end
104end
105
106# workqueue_count MUST be 0 or else Blather uses threads!
107setup(
108	CONFIG[:component][:jid],
109	CONFIG[:component][:secret],
110	CONFIG[:server][:host],
111	CONFIG[:server][:port],
112	nil,
113	nil,
114	workqueue_count: 0
115)
116
117message to: /\Aaccount@/ do |m|
118	self << m.reply.tap do |out|
119		out.body = "This bot is deprecated. Please talk to xmpp:cheogram.com"
120	end
121end
122
123message to: /\Acustomer_/, from: /@#{CONFIG[:sgx]}(\/|\Z)/ do |m|
124	Customer.for_customer_id(
125		m.to.node.delete_prefix("customer_")
126	).then { |customer| customer.stanza_to(m) }.catch(&method(:panic))
127end
128
129message do |m|
130	Customer.for_jid(m.from.stripped).then { |customer|
131		today = Time.now.utc.to_date
132		EMPromise.all([
133			REDIS.zremrangebylex(
134				"jmp_customer_outbound_messages-#{customer.customer_id}",
135				"-",
136				# Store message counts per day for 1 year
137				"[#{(today << 12).strftime('%Y%m%d')}"
138			),
139			REDIS.zincrby(
140				"jmp_customer_outbound_messages-#{customer.customer_id}",
141				1,
142				today.strftime("%Y%m%d")
143			),
144			customer.stanza_from(m)
145		])
146	}.catch(&method(:panic))
147end
148
149message :error? do |m|
150	puts "MESSAGE ERROR: #{m.inspect}"
151end
152
153class SessionManager
154	def initialize(blather, id_msg, timeout: 5)
155		@blather = blather
156		@sessions = {}
157		@id_msg = id_msg
158		@timeout = timeout
159	end
160
161	def promise_for(stanza)
162		id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}"
163		@sessions.fetch(id) do
164			@sessions[id] = EMPromise.new
165			EM.add_timer(@timeout) do
166				@sessions.delete(id)&.reject(:timeout)
167			end
168			@sessions[id]
169		end
170	end
171
172	def write(stanza)
173		promise = promise_for(stanza)
174		@blather << stanza
175		promise
176	end
177
178	def fulfill(stanza)
179		id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
180		if stanza.error?
181			@sessions.delete(id)&.reject(stanza)
182		else
183			@sessions.delete(id)&.fulfill(stanza)
184		end
185	end
186end
187
188IQ_MANAGER = SessionManager.new(self, :id)
189COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
190web_register_manager = WebRegisterManager.new
191
192disco_info to: Blather::JID.new(CONFIG[:component][:jid]) do |iq|
193	reply = iq.reply
194	reply.identities = [{
195		name: "JMP.chat",
196		type: "sms",
197		category: "gateway"
198	}]
199	form = Blather::Stanza::X.find_or_create(reply.query)
200	form.type = "result"
201	form.fields = [
202		{
203			var: "FORM_TYPE",
204			type: "hidden",
205			value: "http://jabber.org/network/serverinfo"
206		}
207	] + CONFIG[:xep0157]
208	self << reply
209end
210
211disco_items node: "http://jabber.org/protocol/commands" do |iq|
212	reply = iq.reply
213	reply.items = [
214		# TODO: don't show this item if no braintree methods available
215		# TODO: don't show this item if no plan for this customer
216		Blather::Stanza::DiscoItems::Item.new(
217			iq.to,
218			"buy-credit",
219			"Buy account credit"
220		),
221		Blather::Stanza::DiscoItems::Item.new(
222			iq.to,
223			"jabber:iq:register",
224			"Register"
225		)
226	]
227	self << reply
228end
229
230command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
231	Customer.for_jid(iq.from.stripped).catch {
232		Customer.create(iq.from.stripped)
233	}.then { |customer|
234		Registration.for(
235			iq,
236			customer,
237			web_register_manager
238		).then(&:write)
239	}.catch(&method(:panic))
240end
241
242def reply_with_note(iq, text, type: :info)
243	reply = iq.reply
244	reply.status = :completed
245	reply.note_type = type
246	reply.note_text = text
247
248	self << reply
249end
250
251command :execute?, node: "buy-credit", sessionid: nil do |iq|
252	reply = iq.reply
253	reply.allowed_actions = [:complete]
254
255	Customer.for_jid(iq.from.stripped).then { |customer|
256		BuyAccountCreditForm.for(customer).then do |credit_form|
257			credit_form.add_to_form(reply.form)
258			COMMAND_MANAGER.write(reply).then { |iq2| [customer, credit_form, iq2] }
259		end
260	}.then { |(customer, credit_form, iq2)|
261		iq = iq2 # This allows the catch to use it also
262		Transaction.sale(customer, **credit_form.parse(iq2.form))
263	}.then { |transaction|
264		transaction.insert.then { transaction.amount }
265	}.then { |amount|
266		reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.")
267	}.catch { |e|
268		text = "Failed to buy credit, system said: #{e.message}"
269		reply_with_note(iq, text, type: :error)
270	}.catch(&method(:panic))
271end
272
273command :execute?, node: "web-register", sessionid: nil do |iq|
274	jid = iq.form.field("jid")&.value.to_s.strip
275	tel = iq.form.field("tel")&.value.to_s.strip
276	if iq.from.stripped != CONFIG[:web_register][:from]
277		BLATHER << iq.as_error("forbidden", :auth)
278	elsif jid == "" || tel !~ /\A\+\d+\Z/
279		reply_with_note(iq, "Invalid JID or telephone number.", type: :error)
280	else
281		IQ_MANAGER.write(Blather::Stanza::Iq::Command.new.tap { |cmd|
282			cmd.to = CONFIG[:web_register][:to]
283			cmd.from = CONFIG[:component][:jid]
284			cmd.node = "push-register"
285			cmd.form.fields = [var: "to", value: jid]
286			cmd.form.type = "submit"
287		}).then do |result|
288			final_jid = result.form.field("from")&.value.to_s.strip
289			web_register_manager[final_jid] = tel
290			BLATHER << iq.reply.tap { |reply| reply.status = :completed }
291		end
292	end
293end
294
295command sessionid: /./ do |iq|
296	COMMAND_MANAGER.fulfill(iq)
297end
298
299iq :result? do |iq|
300	IQ_MANAGER.fulfill(iq)
301end
302
303iq :error? do |iq|
304	IQ_MANAGER.fulfill(iq)
305end