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