# frozen_string_literal: true require "pg/em" require "bigdecimal" require "blather/client/dsl" # Require this first to not auto-include require "blather/client" require "braintree" require "dhall" require "em-hiredis" require "em_promise" singleton_class.class_eval do include Blather::DSL Blather::DSL.append_features(self) end require_relative "lib/btc_sell_prices" require_relative "lib/buy_account_credit_form" require_relative "lib/customer" require_relative "lib/electrum" require_relative "lib/em" require_relative "lib/payment_methods" require_relative "lib/registration" require_relative "lib/transaction" require_relative "lib/web_register_manager" CONFIG = Dhall::Coder .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc]) .load(ARGV[0], transform_keys: ->(k) { k&.to_sym }) ELECTRUM = Electrum.new(**CONFIG[:electrum]) # Braintree is not async, so wrap in EM.defer for now class AsyncBraintree def initialize(environment:, merchant_id:, public_key:, private_key:, **) @gateway = Braintree::Gateway.new( environment: environment, merchant_id: merchant_id, public_key: public_key, private_key: private_key ) end def respond_to_missing?(m, *) @gateway.respond_to?(m) end def method_missing(m, *args) return super unless respond_to_missing?(m, *args) EM.promise_defer(klass: PromiseChain) do @gateway.public_send(m, *args) end end class PromiseChain < EMPromise def respond_to_missing?(*) false # We don't actually know what we respond to... end def method_missing(m, *args) return super if respond_to_missing?(m, *args) self.then { |o| o.public_send(m, *args) } end end end BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree]) def panic(e) warn "Error raised during event loop: #{e.message}" warn e.backtrace if e.respond_to?(:backtrace) exit 1 end EM.error_handler(&method(:panic)) when_ready do BLATHER = self REDIS = EM::Hiredis.connect BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id]) DB = PG::EM::Client.new(dbname: "jmp") DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB) DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB) EM.add_periodic_timer(3600) do ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host]) ping.from = CONFIG[:component][:jid] self << ping end end # workqueue_count MUST be 0 or else Blather uses threads! setup( CONFIG[:component][:jid], CONFIG[:component][:secret], CONFIG[:server][:host], CONFIG[:server][:port], nil, nil, workqueue_count: 0 ) message :error? do |m| puts "MESSAGE ERROR: #{m.inspect}" end class SessionManager def initialize(blather, id_msg, timeout: 5) @blather = blather @sessions = {} @id_msg = id_msg @timeout = timeout end def promise_for(stanza) id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}" @sessions.fetch(id) do @sessions[id] = EMPromise.new EM.add_timer(@timeout) do @sessions.delete(id)&.reject(:timeout) end @sessions[id] end end def write(stanza) promise = promise_for(stanza) @blather << stanza promise end def fulfill(stanza) id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}" if stanza.error? @sessions.delete(id)&.reject(stanza) else @sessions.delete(id)&.fulfill(stanza) end end end IQ_MANAGER = SessionManager.new(self, :id) COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60) web_register_manager = WebRegisterManager.new disco_items node: "http://jabber.org/protocol/commands" do |iq| reply = iq.reply reply.items = [ # TODO: don't show this item if no braintree methods available # TODO: don't show this item if no plan for this customer Blather::Stanza::DiscoItems::Item.new( iq.to, "buy-credit", "Buy account credit" ), Blather::Stanza::DiscoItems::Item.new( iq.to, "jabber:iq:register", "Register" ) ] self << reply end command :execute?, node: "jabber:iq:register", sessionid: nil do |iq| Customer.for_jid(iq.from.stripped).catch { nil }.then { |customer| Registration.for( iq, customer, web_register_manager ).then(&:write) }.catch(&method(:panic)) 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 command :execute?, node: "buy-credit", sessionid: nil do |iq| reply = iq.reply reply.allowed_actions = [:complete] Customer.for_jid(iq.from.stripped).then { |customer| BuyAccountCreditForm.new(customer).add_to_form(reply.form).then { customer } }.then { |customer| EMPromise.all([ customer.payment_methods, customer.merchant_account, COMMAND_MANAGER.write(reply) ]) }.then { |(payment_methods, merchant_account, iq2)| iq = iq2 # This allows the catch to use it also payment_method = payment_methods.fetch( iq.form.field("payment_method")&.value.to_i ) amount = iq.form.field("amount").value.to_s Transaction.sale(merchant_account, payment_method, amount) }.then { |transaction| transaction.insert.then { transaction.amount } }.then { |amount| reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.") }.catch { |e| text = "Failed to buy credit, system said: #{e.message}" reply_with_note(iq, text, type: :error) }.catch(&method(:panic)) end command sessionid: /./ do |iq| COMMAND_MANAGER.fulfill(iq) end iq :result? do |iq| IQ_MANAGER.fulfill(iq) end iq :error? do |iq| IQ_MANAGER.fulfill(iq) end