# 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 "date" require "dhall" require "em-hiredis" require "em_promise" require "ruby-bandwidth-iris" require "sentry-ruby" Sentry.init CONFIG = Dhall::Coder .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc]) .load(ARGV[0], transform_keys: ->(k) { k&.to_sym }) singleton_class.class_eval do include Blather::DSL Blather::DSL.append_features(self) end require_relative "lib/backend_sgx" require_relative "lib/bandwidth_tn_order" require_relative "lib/btc_sell_prices" require_relative "lib/buy_account_credit_form" require_relative "lib/command_list" 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" ELECTRUM = Electrum.new(**CONFIG[:electrum]) Faraday.default_adapter = :em_synchrony BandwidthIris::Client.global_options = { account_id: CONFIG[:creds][:account], username: CONFIG[:creds][:username], password: CONFIG[:creds][:password] } def new_sentry_hub(stanza, name: nil) hub = Sentry.get_current_hub&.new_from_top raise "Sentry.init has not been called" unless hub hub.push_scope hub.current_scope.clear_breadcrumbs hub.current_scope.set_transaction_name(name) if name hub.current_scope.set_user(jid: stanza.from.stripped.to_s) hub end # 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, hub=nil) m = e.respond_to?(:message) ? e.message : e warn "Error raised during event loop: #{e.class}: #{m}" warn e.backtrace if e.respond_to?(:backtrace) if e.is_a?(::Exception) (hub || Sentry).capture_exception(e, hint: { background: false }) else (hub || Sentry).capture_message(e.to_s, hint: { background: false }) end 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 to: /\Aaccount@/ do |m| self << m.reply.tap do |out| out.body = "This bot is deprecated. Please talk to xmpp:cheogram.com" end end before nil, to: /\Acustomer_/, from: /(\A|@)#{CONFIG[:sgx]}(\/|\Z)/ do |s| sentry_hub = new_sentry_hub(s, name: "stanza_customer") Customer.for_customer_id( s.to.node.delete_prefix("customer_") ).then { |customer| sentry_hub.current_scope.set_user( id: customer.customer_id, jid: s.from.stripped.to_s ) customer.stanza_to(s) }.catch { |e| panic(e, sentry_hub) } halt end message do |m| sentry_hub = new_sentry_hub(m, name: "message") Customer.for_jid(m.from.stripped).then { |customer| sentry_hub.current_scope.set_user( id: customer.customer_id, jid: m.from.stripped.to_s ) today = Time.now.utc.to_date EMPromise.all([ REDIS.zremrangebylex( "jmp_customer_outbound_messages-#{customer.customer_id}", "-", # Store message counts per day for 1 year "[#{(today << 12).strftime('%Y%m%d')}" ), REDIS.zincrby( "jmp_customer_outbound_messages-#{customer.customer_id}", 1, today.strftime("%Y%m%d") ), customer.stanza_from(m) ]) }.catch { |e| panic(e, sentry_hub) } end 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_info to: Blather::JID.new(CONFIG[:component][:jid]) do |iq| reply = iq.reply reply.identities = [{ name: "JMP.chat", type: "sms", category: "gateway" }] form = Blather::Stanza::X.find_or_create(reply.query) form.type = "result" form.fields = [ { var: "FORM_TYPE", type: "hidden", value: "http://jabber.org/network/serverinfo" } ] + CONFIG[:xep0157] self << reply end disco_items node: "http://jabber.org/protocol/commands" do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) reply = iq.reply CommandList.for(iq.from.stripped).then { |list| reply.items = list.map do |item| Blather::Stanza::DiscoItems::Item.new( iq.to, item[:node], item[:name] ) end self << reply }.catch { |e| panic(e, sentry_hub) } end command :execute?, node: "jabber:iq:register", sessionid: nil do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) EMPromise.resolve(nil).then { Customer.for_jid(iq.from.stripped) }.catch { sentry_hub.add_breadcrumb(Sentry::Breadcrumb.new( message: "Customer.create" )) Customer.create(iq.from.stripped) }.then { |customer| sentry_hub.current_scope.set_user( id: customer.customer_id, jid: iq.from.stripped.to_s ) sentry_hub.add_breadcrumb(Sentry::Breadcrumb.new( message: "Registration.for" )) Registration.for( iq, customer, web_register_manager ).then(&:write) }.catch_only(Blather::Stanza) { |reply| self << reply }.catch { |e| panic(e, sentry_hub) } 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 # Commands that just pass through to the SGX command node: [ "number-display", "configure-calls", "record-voicemail-greeting" ] do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) Customer.for_jid(iq.from.stripped).then { |customer| sentry_hub.current_scope.set_user( id: customer.customer_id, jid: iq.from.stripped.to_s ) customer.stanza_from(iq) }.catch { |e| panic(e, sentry_hub) } end command :execute?, node: "buy credit", sessionid: nil do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) reply = iq.reply reply.allowed_actions = [:complete] Customer.for_jid(iq.from.stripped).then { |customer| BuyAccountCreditForm.for(customer).then do |credit_form| credit_form.add_to_form(reply.form) COMMAND_MANAGER.write(reply).then { |iq2| [customer, credit_form, iq2] } end }.then { |(customer, credit_form, iq2)| iq = iq2 # This allows the catch to use it also Transaction.sale(customer, **credit_form.parse(iq2.form)) }.then { |transaction| transaction.insert.then { transaction.amount } }.then { |amount| reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.") }.catch { |e| sentry_hub.capture_exception(e) text = "Failed to buy credit, system said: #{e.message}" reply_with_note(iq, text, type: :error) }.catch { |e| panic(e, sentry_hub) } end command :execute?, node: "reset sip account", sessionid: nil do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) Customer.for_jid(iq.from.stripped).then { |customer| sentry_hub.current_scope.set_user( id: customer.customer_id, jid: iq.from.stripped.to_s ) customer.reset_sip_account }.then { |sip_account| reply = iq.reply reply.command << sip_account.form BLATHER << reply }.catch { |e| panic(e, sentry_hub) } end command :execute?, node: "usage", sessionid: nil do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) report_for = (Date.today..(Date.today << 1)) Customer.for_jid(iq.from.stripped).then { |customer| sentry_hub.current_scope.set_user( id: customer.customer_id, jid: iq.from.stripped.to_s ) customer.usage_report(report_for) }.then { |usage_report| reply = iq.reply reply.status = :completed reply.command << usage_report.form BLATHER << reply }.catch { |e| panic(e, sentry_hub) } end command :execute?, node: "web-register", sessionid: nil do |iq| sentry_hub = new_sentry_hub(iq, name: iq.node) begin jid = iq.form.field("jid")&.value.to_s.strip tel = iq.form.field("tel")&.value.to_s.strip hub.current_scope.set_user(jid: jid, tel: tel) if iq.from.stripped != CONFIG[:web_register][:from] BLATHER << iq.as_error("forbidden", :auth) elsif jid == "" || tel !~ /\A\+\d+\Z/ reply_with_note(iq, "Invalid JID or telephone number.", type: :error) else IQ_MANAGER.write(Blather::Stanza::Iq::Command.new.tap { |cmd| cmd.to = CONFIG[:web_register][:to] cmd.from = CONFIG[:component][:jid] cmd.node = "push-register" cmd.form.fields = [var: "to", value: jid] cmd.form.type = "submit" }).then { |result| final_jid = result.form.field("from")&.value.to_s.strip web_register_manager[final_jid] = tel BLATHER << iq.reply.tap { |reply| reply.status = :completed } }.catch { |e| panic(e, sentry_hub) } end rescue StandardError => e sentry_hub.capture_exception(e) end 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