# frozen_string_literal: true
require "pg/em"
require "bigdecimal"
require "blather/client"
require "braintree"
require "dhall"
require "em-hiredis"
require "em_promise"
require "time-hash"
CONFIG =
Dhall::Coder
.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
.load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
# 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 respond_to_missing?(m, *args)
self.then { |o| o.public_send(m, *args) }
end
end
end
BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
def node(name, parent, ns: nil)
Niceogiri::XML::Node.new(
name,
parent.document,
ns || parent.class.registered_ns
)
end
def escape_jid(localpart)
# TODO: proper XEP-0106 Sec 4.3, ie. pre-escaped
localpart
.to_s
.gsub("\\", "\\\\5c")
.gsub(" ", "\\\\20")
.gsub("\"", "\\\\22")
.gsub("&", "\\\\26")
.gsub("'", "\\\\27")
.gsub("/", "\\\\2f")
.gsub(":", "\\\\3a")
.gsub("<", "\\\\3c")
.gsub(">", "\\\\3e")
.gsub("@", "\\\\40")
end
def unescape_jid(localpart)
localpart
.to_s
.gsub("\\20", " ")
.gsub("\\22", "\"")
.gsub("\\26", "&")
.gsub("\\27", "'")
.gsub("\\2f", "/")
.gsub("\\3a", ":")
.gsub("\\3c", "<")
.gsub("\\3e", ">")
.gsub("\\40", "@")
.gsub("\\5c", "\\")
end
def proxy_jid(jid)
Blather::JID.new(
escape_jid(jid.stripped),
CONFIG[:component][:jid],
jid.resource
)
end
def unproxy_jid(jid)
parsed = Blather::JID.new(unescape_jid(jid.node))
Blather::JID.new(parsed.node, parsed.domain, jid.resource)
end
class IBR < Blather::Stanza::Iq::Query
register :ibr, nil, "jabber:iq:register"
def registered=(reg)
query.at_xpath("./ns:registered", ns: self.class.registered_ns)&.remove
query << node("registered", self) if reg
end
def registered?
!!query.at_xpath("./ns:registered", ns: self.class.registered_ns)
end
[
"instructions",
"username",
"nick",
"password",
"name",
"first",
"last",
"last",
"email",
"address",
"city",
"state",
"zip",
"phone",
"url",
"date"
].each do |tag|
define_method("#{tag}=") do |v|
query.at_xpath("./ns:#{tag}", ns: self.class.registered_ns)&.remove
query << (i = node(tag, self))
i.content = v
end
define_method(tag) do
query.at_xpath("./ns:#{tag}", ns: self.class.registered_ns)&.content
end
end
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]
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
ibr :get? do |iq|
fwd = iq.dup
fwd.from = proxy_jid(iq.from)
fwd.to = Blather::JID.new(nil, CONFIG[:sgx], iq.to.resource)
fwd.id = "JMPGET%#{iq.id}"
self << fwd
end
ibr :result? do |iq|
if iq.id.start_with?("JMPGET")
reply = iq.reply
reply.instructions =
"Please enter the phone number you wish to register with JMP.chat"
reply.registered = iq.registered?
reply.phone = iq.phone
else
reply = iq.dup
end
reply.id = iq.id.sub(/JMP[GS]ET%/, "")
reply.from = Blather::JID.new(
nil,
CONFIG[:component][:jid],
iq.from.resource
)
reply.to = unproxy_jid(iq.to)
self << reply
end
ibr :error? do |iq|
reply = iq.dup
reply.id = iq.id.sub(/JMP[GS]ET%/, "")
reply.from = Blather::JID.new(
nil,
CONFIG[:component][:jid],
iq.from.resource
)
reply.to = unproxy_jid(iq.to)
self << reply
end
ibr :set? do |iq|
fwd = iq.dup
CONFIG[:creds].each do |k, v|
fwd.public_send("#{k}=", v)
end
fwd.from = proxy_jid(iq.from)
fwd.to = Blather::JID.new(nil, CONFIG[:sgx], iq.to.resource)
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
# TODO: don't show this item if no plan for this customer
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 COALESCE(balance,0) AS balance, plan_name FROM " \
"balances LEFT JOIN customer_plans USING (customer_id) " \
"WHERE customer_id=$1 LIMIT 1",
[customer_id]
).then do |rows|
rows.first || { "balance" => BigDecimal.new(0) }
end,
BRAINTREE.customer.find(customer_id).payment_methods
])
}.then { |(row, payment_methods)|
raise "No payment methods available" if payment_methods.empty?
plan = CONFIG[:plans].find { |p| p[:name] == row["plan_name"] }
raise "No plan for this customer" unless plan
merchant_account = CONFIG[:braintree][:merchant_accounts][plan[:currency]]
raise "No merchant account for this currency" unless merchant_account
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: $#{'%.2f' % row['balance']}"
},
if payment_methods.length > 1
{
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
}
end,
XEP0122Field.new(
"xs:decimal",
range: (0..1000),
var: "amount",
label: "Amount of credit to buy",
required: true
).field
].compact
EMPromise.all([
payment_methods,
merchant_account,
command_reply_and_promise(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
)
BRAINTREE.transaction.sale(
amount: iq.form.field("amount").value.to_s,
payment_method_token: payment_method.token,
merchant_account_id: merchant_account,
options: { submit_for_settlement: true }
)
}.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 = "$#{'%.2f' % amount} 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