# 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.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, 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 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