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 "date"
9require "dhall"
10require "em-hiredis"
11require "em_promise"
12require "ruby-bandwidth-iris"
13
14CONFIG =
15 Dhall::Coder
16 .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc])
17 .load(ARGV[0], transform_keys: ->(k) { k&.to_sym })
18
19singleton_class.class_eval do
20 include Blather::DSL
21 Blather::DSL.append_features(self)
22end
23
24require_relative "lib/backend_sgx"
25require_relative "lib/bandwidth_tn_order"
26require_relative "lib/btc_sell_prices"
27require_relative "lib/buy_account_credit_form"
28require_relative "lib/customer"
29require_relative "lib/electrum"
30require_relative "lib/em"
31require_relative "lib/payment_methods"
32require_relative "lib/registration"
33require_relative "lib/transaction"
34require_relative "lib/web_register_manager"
35
36ELECTRUM = Electrum.new(**CONFIG[:electrum])
37
38Faraday.default_adapter = :em_synchrony
39BandwidthIris::Client.global_options = {
40 account_id: CONFIG[:creds][:account],
41 username: CONFIG[:creds][:username],
42 password: CONFIG[:creds][:password]
43}
44
45# Braintree is not async, so wrap in EM.defer for now
46class AsyncBraintree
47 def initialize(environment:, merchant_id:, public_key:, private_key:, **)
48 @gateway = Braintree::Gateway.new(
49 environment: environment,
50 merchant_id: merchant_id,
51 public_key: public_key,
52 private_key: private_key
53 )
54 end
55
56 def respond_to_missing?(m, *)
57 @gateway.respond_to?(m)
58 end
59
60 def method_missing(m, *args)
61 return super unless respond_to_missing?(m, *args)
62
63 EM.promise_defer(klass: PromiseChain) do
64 @gateway.public_send(m, *args)
65 end
66 end
67
68 class PromiseChain < EMPromise
69 def respond_to_missing?(*)
70 false # We don't actually know what we respond to...
71 end
72
73 def method_missing(m, *args)
74 return super if respond_to_missing?(m, *args)
75 self.then { |o| o.public_send(m, *args) }
76 end
77 end
78end
79
80BRAINTREE = AsyncBraintree.new(**CONFIG[:braintree])
81
82def panic(e)
83 m = e.respond_to?(:message) ? e.message : e
84 warn "Error raised during event loop: #{e.class}: #{m}"
85 warn e.backtrace if e.respond_to?(:backtrace)
86 exit 1
87end
88
89EM.error_handler(&method(:panic))
90
91when_ready do
92 BLATHER = self
93 REDIS = EM::Hiredis.connect
94 BTC_SELL_PRICES = BTCSellPrices.new(REDIS, CONFIG[:oxr_app_id])
95 DB = PG::EM::Client.new(dbname: "jmp")
96 DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
97 DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
98
99 EM.add_periodic_timer(3600) do
100 ping = Blather::Stanza::Iq::Ping.new(:get, CONFIG[:server][:host])
101 ping.from = CONFIG[:component][:jid]
102 self << ping
103 end
104end
105
106# workqueue_count MUST be 0 or else Blather uses threads!
107setup(
108 CONFIG[:component][:jid],
109 CONFIG[:component][:secret],
110 CONFIG[:server][:host],
111 CONFIG[:server][:port],
112 nil,
113 nil,
114 workqueue_count: 0
115)
116
117message to: /\Aaccount@/ do |m|
118 self << m.reply.tap do |out|
119 out.body = "This bot is deprecated. Please talk to xmpp:cheogram.com"
120 end
121end
122
123message to: /\Acustomer_/, from: /@#{CONFIG[:sgx]}(\/|\Z)/ do |m|
124 Customer.for_customer_id(
125 m.to.node.delete_prefix("customer_")
126 ).then { |customer| customer.stanza_to(m) }.catch(&method(:panic))
127end
128
129message do |m|
130 Customer.for_jid(m.from.stripped).then { |customer|
131 today = Time.now.utc.to_date
132 EMPromise.all([
133 REDIS.zremrangebylex(
134 "jmp_customer_outbound_messages-#{customer.customer_id}",
135 "-",
136 # Store message counts per day for 1 year
137 "[#{(today << 12).strftime('%Y%m%d')}"
138 ),
139 REDIS.zincrby(
140 "jmp_customer_outbound_messages-#{customer.customer_id}",
141 1,
142 today.strftime("%Y%m%d")
143 ),
144 customer.stanza_from(m)
145 ])
146 }.catch(&method(:panic))
147end
148
149message :error? do |m|
150 puts "MESSAGE ERROR: #{m.inspect}"
151end
152
153class SessionManager
154 def initialize(blather, id_msg, timeout: 5)
155 @blather = blather
156 @sessions = {}
157 @id_msg = id_msg
158 @timeout = timeout
159 end
160
161 def promise_for(stanza)
162 id = "#{stanza.to.stripped}/#{stanza.public_send(@id_msg)}"
163 @sessions.fetch(id) do
164 @sessions[id] = EMPromise.new
165 EM.add_timer(@timeout) do
166 @sessions.delete(id)&.reject(:timeout)
167 end
168 @sessions[id]
169 end
170 end
171
172 def write(stanza)
173 promise = promise_for(stanza)
174 @blather << stanza
175 promise
176 end
177
178 def fulfill(stanza)
179 id = "#{stanza.from.stripped}/#{stanza.public_send(@id_msg)}"
180 if stanza.error?
181 @sessions.delete(id)&.reject(stanza)
182 else
183 @sessions.delete(id)&.fulfill(stanza)
184 end
185 end
186end
187
188IQ_MANAGER = SessionManager.new(self, :id)
189COMMAND_MANAGER = SessionManager.new(self, :sessionid, timeout: 60 * 60)
190web_register_manager = WebRegisterManager.new
191
192disco_info to: Blather::JID.new(CONFIG[:component][:jid]) do |iq|
193 reply = iq.reply
194 reply.identities = [{
195 name: "JMP.chat",
196 type: "sms",
197 category: "gateway"
198 }]
199 form = Blather::Stanza::X.find_or_create(reply.query)
200 form.type = "result"
201 form.fields = [
202 {
203 var: "FORM_TYPE",
204 type: "hidden",
205 value: "http://jabber.org/network/serverinfo"
206 }
207 ] + CONFIG[:xep0157]
208 self << reply
209end
210
211disco_items node: "http://jabber.org/protocol/commands" do |iq|
212 reply = iq.reply
213 reply.items = [
214 # TODO: don't show this item if no braintree methods available
215 # TODO: don't show this item if no plan for this customer
216 Blather::Stanza::DiscoItems::Item.new(
217 iq.to,
218 "buy-credit",
219 "Buy account credit"
220 ),
221 Blather::Stanza::DiscoItems::Item.new(
222 iq.to,
223 "jabber:iq:register",
224 "Register"
225 )
226 ]
227 self << reply
228end
229
230command :execute?, node: "jabber:iq:register", sessionid: nil do |iq|
231 Customer.for_jid(iq.from.stripped).catch {
232 Customer.create(iq.from.stripped)
233 }.then { |customer|
234 Registration.for(
235 iq,
236 customer,
237 web_register_manager
238 ).then(&:write)
239 }.catch(&method(:panic))
240end
241
242def reply_with_note(iq, text, type: :info)
243 reply = iq.reply
244 reply.status = :completed
245 reply.note_type = type
246 reply.note_text = text
247
248 self << reply
249end
250
251command :execute?, node: "buy-credit", sessionid: nil do |iq|
252 reply = iq.reply
253 reply.allowed_actions = [:complete]
254
255 Customer.for_jid(iq.from.stripped).then { |customer|
256 BuyAccountCreditForm.for(customer).then do |credit_form|
257 credit_form.add_to_form(reply.form)
258 COMMAND_MANAGER.write(reply).then { |iq2| [customer, credit_form, iq2] }
259 end
260 }.then { |(customer, credit_form, iq2)|
261 iq = iq2 # This allows the catch to use it also
262 Transaction.sale(customer, **credit_form.parse(iq2.form))
263 }.then { |transaction|
264 transaction.insert.then { transaction.amount }
265 }.then { |amount|
266 reply_with_note(iq, "$#{'%.2f' % amount} added to your account balance.")
267 }.catch { |e|
268 text = "Failed to buy credit, system said: #{e.message}"
269 reply_with_note(iq, text, type: :error)
270 }.catch(&method(:panic))
271end
272
273command :execute?, node: "web-register", sessionid: nil do |iq|
274 jid = iq.form.field("jid")&.value.to_s.strip
275 tel = iq.form.field("tel")&.value.to_s.strip
276 if iq.from.stripped != CONFIG[:web_register][:from]
277 BLATHER << iq.as_error("forbidden", :auth)
278 elsif jid == "" || tel !~ /\A\+\d+\Z/
279 reply_with_note(iq, "Invalid JID or telephone number.", type: :error)
280 else
281 IQ_MANAGER.write(Blather::Stanza::Iq::Command.new.tap { |cmd|
282 cmd.to = CONFIG[:web_register][:to]
283 cmd.from = CONFIG[:component][:jid]
284 cmd.node = "push-register"
285 cmd.form.fields = [var: "to", value: jid]
286 cmd.form.type = "submit"
287 }).then do |result|
288 final_jid = result.form.field("from")&.value.to_s.strip
289 web_register_manager[final_jid] = tel
290 BLATHER << iq.reply.tap { |reply| reply.status = :completed }
291 end
292 end
293end
294
295command sessionid: /./ do |iq|
296 COMMAND_MANAGER.fulfill(iq)
297end
298
299iq :result? do |iq|
300 IQ_MANAGER.fulfill(iq)
301end
302
303iq :error? do |iq|
304 IQ_MANAGER.fulfill(iq)
305end