1# frozen_string_literal: true
2
3require "pg/em"
4require "bigdecimal"
5require "blather/client/dsl" # Require this first to not auto-include
6require "blather/client"
7require "braintree"
8require "dhall"
9require "em-hiredis"
10require "em_promise"
11require "ruby-bandwidth-iris"
12
13CONFIG =
14 Dhall::Coder
15 .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
16 .load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
17
18singleton_class.class_eval do
19 include Blather::DSL
20 Blather::DSL.append_features(self)
21end
22
23require_relative "lib/backend_sgx"
24require_relative "lib/bandwidth_tn_order"
25require_relative "lib/btc_sell_prices"
26require_relative "lib/buy_account_credit_form"
27require_relative "lib/customer"
28require_relative "lib/electrum"
29require_relative "lib/em"
30require_relative "lib/payment_methods"
31require_relative "lib/registration"
32require_relative "lib/transaction"
33require_relative "lib/web_register_manager"
34
35ELECTRUM = Electrum.new(**CONFIG[:electrum])
36
37Faraday.default_adapter = :em_synchrony
38BandwidthIris::Client.global_options = {
39 account_id: CONFIG[:creds][:account],
40 username: CONFIG[:creds][:username],
41 password: CONFIG[:creds][:password]
42}
43
44# Braintree is not async, so wrap in EM.defer for now
45class AsyncBraintree
46 def initialize(environment:, merchant_id:, public_key:, private_key:, **)
47 @gateway = Braintree::Gateway.new(
48 environment: environment,
49 merchant_id: merchant_id,
50 public_key: public_key,
51 private_key: private_key
52 )
53 end
54
55 def respond_to_missing?(m, *)
56 @gateway.respond_to?(m)
57 end
58
59 def method_missing(m, *args)
60 return super unless respond_to_missing?(m, *args)
61
62 EM.promise_defer(klass: PromiseChain) do
63 @gateway.public_send(m, *args)
64 end
65 end
66
67 class PromiseChain < EMPromise
68 def respond_to_missing?(*)
69 false # We don't actually know what we respond to...
70 end
71
72 def method_missing(m, *args)
73 return super if respond_to_missing?(m, *args)
74 self.then { |o| o.public_send(m, *args) }
75 end
76 end
77end
78
79BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
80
81def panic(e)
82 m = e.respond_to?(:message) ? e.message : e
83 warn "Error raised during event loop: #{e.class}: #{m}"
84 warn e.backtrace if e.respond_to?(:backtrace)
85 exit 1
86end
87
88EM.error_handler(&method(:panic))
89
90when_ready do
91 BLATHER = self
92 REDIS = EM::Hiredis.connect
93 BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
94 DB = PG::EM::Client.new(dbname: "jmp")
95 DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
96 DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
97
98 EM.add_periodic_timer(3600) do
99 ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
100 ping.from = CONFIG[:component][:jid]
101 self << ping
102 end
103end
104
105# workqueue_count MUST be 0 or else Blather uses threads!
106setup(
107 CONFIG[:component][:jid],
108 CONFIG[:component][:secret],
109 CONFIG[:server][:host],
110 CONFIG[:server][:port],
111 nil,
112 nil,
113 workqueue_count: 0
114)
115
116message to: /\Acustomer_/, from: /@#{CONFIG[:sgx]}(\/|\Z)/ do |m|
117 Customer.for_customer_id(
118 m.to.node.delete_prefix("customer_")
119 ).then { |customer| customer.stanza_to(m) }.catch(&method(:panic))
120end
121
122message do |m|
123 Customer.for_jid(m.from.stripped).then { |customer|
124 customer.stanza_from(m)
125 }.catch(&method(:panic))
126end
127
128message :error? do |m|
129 puts "MESSAGE ERROR: #{m.inspect}"
130end
131
132class SessionManager
133 def initialize(blather, id_msg, timeout: 5)
134 @blather = blather
135 @sessions = {}
136 @id_msg = id_msg
137 @timeout = timeout
138 end
139
140 def promise_for(stanza)
141 id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}"
142 @sessions.fetch(id) do
143 @sessions[id] = EMPromise.new
144 EM.add_timer(@timeout) do
145 @sessions.delete(id)&.reject(:timeout)
146 end
147 @sessions[id]
148 end
149 end
150
151 def write(stanza)
152 promise = promise_for(stanza)
153 @blather << stanza
154 promise
155 end
156
157 def fulfill(stanza)
158 id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
159 if stanza.error?
160 @sessions.delete(id)&.reject(stanza)
161 else
162 @sessions.delete(id)&.fulfill(stanza)
163 end
164 end
165end
166
167IQ_MANAGER = SessionManager.new(self, :id)
168COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
169web_register_manager = WebRegisterManager.new
170
171disco_items node: "http://jabber.org/protocol/commands" do |iq|
172 reply = iq.reply
173 reply.items = [
174 # TODO: don't show this item if no braintree methods available
175 # TODO: don't show this item if no plan for this customer
176 Blather::Stanza::DiscoItems::Item.new(
177 iq.to,
178 "buy-credit",
179 "Buy account credit"
180 ),
181 Blather::Stanza::DiscoItems::Item.new(
182 iq.to,
183 "jabber:iq:register",
184 "Register"
185 )
186 ]
187 self << reply
188end
189
190command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
191 Customer.for_jid(iq.from.stripped).catch {
192 Customer.create(iq.from.stripped)
193 }.then { |customer|
194 Registration.for(
195 iq,
196 customer,
197 web_register_manager
198 ).then(&:write)
199 }.catch(&method(:panic))
200end
201
202def reply_with_note(iq, text, type: :info)
203 reply = iq.reply
204 reply.status = :completed
205 reply.note_type = type
206 reply.note_text = text
207
208 self << reply
209end
210
211command :execute?, node: "buy-credit", sessionid: nil do |iq|
212 reply = iq.reply
213 reply.allowed_actions = [:complete]
214
215 Customer.for_jid(iq.from.stripped).then { |customer|
216 BuyAccountCreditForm.new(customer).add_to_form(reply.form).then { customer }
217 }.then { |customer|
218 EMPromise.all([
219 customer,
220 customer.payment_methods,
221 COMMAND_MANAGER.write(reply)
222 ])
223 }.then { |(customer, payment_methods, iq2)|
224 iq = iq2 # This allows the catch to use it also
225 payment_method = payment_methods.fetch(
226 iq.form.field("payment_method")&.value.to_i
227 )
228 amount = iq.form.field("amount").value.to_s
229 Transaction.sale(customer, amount, payment_method)
230 }.then { |transaction|
231 transaction.insert.then { transaction.amount }
232 }.then { |amount|
233 reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.")
234 }.catch { |e|
235 text = "Failed to buy credit, system said: #{e.message}"
236 reply_with_note(iq, text, type: :error)
237 }.catch(&method(:panic))
238end
239
240command sessionid: /./ do |iq|
241 COMMAND_MANAGER.fulfill(iq)
242end
243
244iq :result? do |iq|
245 IQ_MANAGER.fulfill(iq)
246end
247
248iq :error? do |iq|
249 IQ_MANAGER.fulfill(iq)
250end