A .gitmodules => .gitmodules +3 -0
@@ 0,0 1,3 @@
+[submodule "schemas"]
+ path = schemas
+ url = https://git.singpolyma.net/jmp-schemas
M .rubocop.yml => .rubocop.yml +12 -0
@@ 28,3 28,15 @@ Style/DoubleNegation:
Layout/SpaceAroundEqualsInParameterDefault:
EnforcedStyle: no_space
+
+Layout/AccessModifierIndentation:
+ EnforcedStyle: outdent
+
+Style/BlockDelimiters:
+ EnforcedStyle: braces_for_chaining
+
+Style/MultilineBlockChain:
+ Enabled: false
+
+Layout/IndentArray:
+ EnforcedStyle: consistent
M Gemfile => Gemfile +9 -1
@@ 3,6 3,14 @@
source "https://rubygems.org"
gem "blather"
+gem "braintree"
gem "dhall"
+gem "em-hiredis"
+gem "em-pg-client", git: "https://github.com/royaltm/ruby-em-pg-client"
+gem "em_promise.rb"
gem "eventmachine"
-gem "promise.rb"
+gem "time-hash"
+
+group(:development) do
+ gem "pry-remote-em"
+end
M config.dhall.sample => config.dhall.sample +6 -0
@@ 12,5 12,11 @@
nick = "userid",
username = "token",
password = "secret"
+ },
+ braintree = {
+ environment = "sandbox",
+ merchant_id = "",
+ public_key = "",
+ private_key = ""
}
}
D em_promise.rb => em_promise.rb +0 -52
@@ 1,52 0,0 @@
-# frozen_string_literal: true
-
-require "eventmachine"
-require "promise"
-
-class EMPromise < Promise
- def initialize(deferrable=nil)
- super()
- fulfill(deferrable) if deferrable
- end
-
- def fulfill(value, bind_defer=true)
- if bind_defer && value.is_a?(EM::Deferrable)
- value.callback { |x| fulfill(x, false) }
- value.errback(&method(:reject))
- else
- super(value)
- end
- end
-
- def defer
- EM.next_tick { yield }
- end
-
- def wait
- fiber = Fiber.current
- resume = proc do |arg|
- defer { fiber.resume(arg) }
- end
-
- self.then(resume, resume)
- Fiber.yield
- end
-
- def self.reject(e)
- new.tap { |promise| promise.reject(e) }
- end
-end
-
-module EventMachine
- module Deferrable
- def promise
- EMPromise.new(self)
- end
-
- [:then, :rescue, :catch].each do |method|
- define_method(method) do |*args, &block|
- promise.public_send(method, *args, &block)
- end
- end
- end
-end
A schemas => schemas +1 -0
@@ 0,0 1,1 @@
+Subproject commit b0729aba768a943ed9f695d1468f1c62f2076727
M sgx_jmp.rb => sgx_jmp.rb +231 -2
@@ 1,12 1,57 @@
# frozen_string_literal: true
+require "pg/em"
+require "bigdecimal"
require "blather/client"
+require "braintree"
require "dhall"
-
-require_relative "em_promise"
+require "em-hiredis"
+require "em_promise"
+require "time-hash"
CONFIG = Dhall::Coder.load(ARGV[0])
+# 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)
+
+ promise = PromiseChain.new
+ EventMachine.defer(
+ -> { @gateway.public_send(m, *args) },
+ promise.method(:fulfill),
+ promise.method(:reject)
+ )
+ promise
+ 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 false # cover everything for now
+ self.then { |o| o.public_send(m, *args) }
+ end
+ end
+end
+
+BRAINTREE = AsyncBraintree.new(**CONFIG["braintree"].transform_keys(&:to_sym))
+
def node(name, parent, ns: nil)
Niceogiri::XML::Node.new(
name,
@@ 103,7 148,19 @@ end
Blather::DSL.append_features(self.class)
+def panic(e)
+ warn "Error raised during event loop: #{e.message}"
+ exit 1
+end
+
+EM.error_handler(&method(:panic))
+
when_ready do
+ REDIS = EM::Hiredis.connect
+ 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"]
@@ 177,3 234,175 @@ ibr :set? do |iq|
fwd.id = "JMPSET%#{iq.id}"
self << fwd
end
+
+@command_sessions = TimeHash.new
+def command_reply_and_promise(reply)
+ promise = EMPromise.new
+ @command_sessions.put(reply.sessionid, promise, 60 * 60)
+ self << reply
+ promise
+end
+
+def command_reply_and_done(reply)
+ @command_sessions.delete(reply.sessionid)
+ self << reply
+end
+
+class XEP0122Field
+ attr_reader :field
+
+ def initialize(type, range: nil, **field)
+ @type = type
+ @range = range
+ @field = Blather::Stanza::X::Field.new(**field)
+ @field.add_child(validate)
+ end
+
+protected
+
+ def validate
+ validate = Nokogiri::XML::Node.new("validate", field.document)
+ validate["xmlns"] = "http://jabber.org/protocol/xdata-validate"
+ validate["datatype"] = @type
+ validate.add_child(validation)
+ validate
+ end
+
+ def validation
+ range_node || begin
+ validation = Nokogiri::XML::Node.new("basic", field.document)
+ validation["xmlns"] = "http://jabber.org/protocol/xdata-validate"
+ end
+ end
+
+ def range_node
+ return unless @range
+
+ validation = Nokogiri::XML::Node.new("range", field.document)
+ validation["xmlns"] = "http://jabber.org/protocol/xdata-validate"
+ validation["min"] = @range.min.to_s if @range.min
+ validation["max"] = @range.max.to_s if @range.max
+ end
+end
+
+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
+ Blather::Stanza::DiscoItems::Item.new(
+ iq.to,
+ "buy-credit",
+ "Buy account credit"
+ )
+ ]
+ self << reply
+end
+
+command :execute?, node: "buy-credit", sessionid: nil do |iq|
+ reply = iq.reply
+ reply.new_sessionid!
+ reply.node = iq.node
+ reply.status = :executing
+ reply.allowed_actions = [:complete]
+
+ REDIS.get("jmp_customer_id-#{iq.from.stripped}").then { |customer_id|
+ raise "No customer id" unless customer_id
+
+ EMPromise.all([
+ DB.query_defer(
+ "SELECT balance FROM balances WHERE customer_id=$1 LIMIT 1",
+ [customer_id]
+ ).then do |rows|
+ rows.first&.dig("balance") || BigDecimal.new(0)
+ end,
+ BRAINTREE.customer.find(customer_id).payment_methods
+ ])
+ }.then { |(balance, payment_methods)|
+ raise "No payment methods available" if payment_methods.empty?
+
+ default_payment_method = payment_methods.index(&:default?)
+
+ form = reply.form
+ form.type = :form
+ form.title = "Buy Account Credit"
+ form.fields = [
+ {
+ type: "fixed",
+ value: "Current balance: $#{balance.to_s('F')}"
+ },
+ {
+ var: "payment_method",
+ type: "list-single",
+ label: "Credit card to pay with",
+ value: default_payment_method.to_s,
+ required: true,
+ options: payment_methods.map.with_index do |method, idx|
+ {
+ value: idx.to_s,
+ label: "#{method.card_type} #{method.last_4}"
+ }
+ end
+ },
+ XEP0122Field.new(
+ "xs:decimal",
+ range: (0..1000),
+ var: "amount",
+ label: "Amount of credit to buy",
+ required: true
+ ).field
+ ]
+
+ EMPromise.all([
+ payment_methods,
+ command_reply_and_promise(reply)
+ ])
+ }.then { |(payment_methods, iq2)|
+ iq = iq2 # This allows the catch to use it also
+ payment_method = payment_methods.fetch(
+ iq.form.field("payment_method").value.to_i
+ )
+ BRAINTREE.transaction.sale(
+ amount: iq.form.field("amount").value.to_s,
+ payment_method_token: payment_method.token
+ )
+ }.then { |braintree_response|
+ raise braintree_response.message unless braintree_response.success?
+ transaction = braintree_response.transaction
+
+ DB.exec_defer(
+ "INSERT INTO transactions " \
+ "(customer_id, transaction_id, created_at, amount) " \
+ "VALUES($1, $2, $3, $4)",
+ [
+ transaction.customer_details.id,
+ transaction.id,
+ transaction.created_at,
+ transaction.amount
+ ]
+ ).then { transaction.amount }
+ }.then { |amount|
+ reply2 = iq.reply
+ reply2.command[:sessionid] = iq.sessionid
+ reply2.node = iq.node
+ reply2.status = :completed
+ note = reply2.note
+ note[:type] = :info
+ note.content = "$#{amount.to_s('F')} added to your account balance."
+
+ command_reply_and_done(reply2)
+ }.catch { |e|
+ reply2 = iq.reply
+ reply2.command[:sessionid] = iq.sessionid
+ reply2.node = iq.node
+ reply2.status = :completed
+ note = reply2.note
+ note[:type] = :error
+ note.content = "Failed to buy credit, system said: #{e.message}"
+
+ command_reply_and_done(reply2)
+ }.catch(&method(:panic))
+end
+
+command sessionid: /./ do |iq|
+ @command_sessions[iq.sessionid]&.fulfill(iq)
+end