From 8846977719850357dc3844db181bf7520d613004 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 23 Jan 2023 18:02:32 -0500 Subject: [PATCH 1/5] Fixup Correct Duplicate Addrs This was a linter error, and I wanted to get it to linter clean before writing new code. --- bin/correct_duplicate_addrs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/correct_duplicate_addrs b/bin/correct_duplicate_addrs index 225cad9f5bdeb457d734bdfc4596f01b9ef7c81b..12e80a1425a47ede1c197be19a7f961ee5c004d6 100755 --- a/bin/correct_duplicate_addrs +++ b/bin/correct_duplicate_addrs @@ -28,7 +28,7 @@ $stdin.each_line do |line| end addr = match[1] - keys = match[2].split(" ") + keys = match[2].split # This is the customer ID of the support chat # All duplicates are moved to the support addr so we still hear when people From 9ab992df37a361171ea653a321417ccd77df1f43 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 23 Jan 2023 18:02:33 -0500 Subject: [PATCH 2/5] Resync Guix Dependencies with Gemfile I tried running this under Guix and it didn't even build. It turns out a lint rule got added that doesn't allow license to have that value any longer, and a few changes to the dependencies of the project have been made without those same changes being reflected in the guix definition. Also, one of the dependencies that made it upstream works upstream, but fails in our definition due to a bundler version issue. A few of these changes may even be out of date by now, in terms of both guix upstream or pointing at branches we don't need to point at due to upstream taking patches, but I wanted to make the minimal set of changes here to make the Gemfile and the Guix have the same information. A later commit can change *both* to point at upstream, etc, if they so choose. --- jmp-pay.scm | 192 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 147 insertions(+), 45 deletions(-) diff --git a/jmp-pay.scm b/jmp-pay.scm index fe636bc9557878638abe70012d2e38956478cd80..901dde01081cdbd53ba9bdfc90f0ed54a46d6374 100644 --- a/jmp-pay.scm +++ b/jmp-pay.scm @@ -43,40 +43,6 @@ "https://github.com/brandonhilkert/sucker_punch") (license license:expat))) -(define-public ruby-niceogiri - (package - (name "ruby-niceogiri") - (version "1.1.2") - (source - (origin - (method url-fetch) - (uri (rubygems-uri "niceogiri" version)) - (sha256 - (base32 - "1ha93211bc9cvh23s9w89zz7rq8irpf64ccd9arvg8v1sxg2798a")))) - (build-system ruby-build-system) - (arguments - `(#:test-target "spec" - #:phases - (modify-phases %standard-phases - (add-after 'extract-gemspec 'less-strict-dependencies - (lambda _ - (substitute* "niceogiri.gemspec" - (("2.7") "3.8") - ((".*guard-rspec.*") "\n")) - #t))))) - (propagated-inputs - `(("ruby-nokogiri" ,ruby-nokogiri))) - (native-inputs - `(("ruby-rspec" ,ruby-rspec) - ("ruby-yard" ,ruby-yard))) - (synopsis "Make dealing with XML less painful") - (description - "Make dealing with XML less painful") - (home-page - "https://github.com/benlangfeld/Niceogiri") - (license license:expat))) - (define-public ruby-countdownlatch (package (name "ruby-countdownlatch") @@ -100,14 +66,19 @@ (define-public ruby-blather (package (name "ruby-blather") - (version "2.0.0") + (version "37fb26593aa776dd91965796e4d9ff79b05f334a") (source (origin - (method url-fetch) - (uri (rubygems-uri "blather" version)) + (method git-fetch) + ;; Download from GitHub because the rubygems version requires old + ;; version of bundler Rakefile. + (uri (git-reference + (url "https://github.com/adhearsion/blather") + (commit version))) + (file-name (git-file-name name version)) (sha256 (base32 - "05ry2x835fj4pzk61282pcz86n018cr39zbgwbi213md74i90s7c")))) + "0k9viwpbd4ifmiv22x2vh2vvc5wgvcxg7x7bwn5nkc5aspisxlq2")))) (build-system ruby-build-system) (arguments `(#:phases @@ -411,14 +382,14 @@ (define-public ruby-dhall (package (name "ruby-dhall") - (version "0.5.2") + (version "0.5.3.fixed") (source (origin (method url-fetch) (uri (rubygems-uri "dhall" version)) (sha256 (base32 - "09wcq8xc1ynld04r2f332bx8cn7rjc4afaq8hm1dr2fc35jlpn6m")))) + "1qn7fpiakzpllks43m7r3wh6a2rypxgg02y09zzk27lhqv6bbbrz")))) (build-system ruby-build-system) (arguments ;; No test in gem archive @@ -592,14 +563,16 @@ (define-public ruby-bandwidth-iris (package (name "ruby-bandwidth-iris") - (version "4.0.0") + (version "list-port-ins") (source (origin - (method url-fetch) - (uri (rubygems-uri "ruby-bandwidth-iris" version)) + (method git-fetch) + (uri (git-reference + (url "https://github.com/singpolyma/ruby-bandwidth-iris") + (commit version))) (sha256 (base32 - "16bbx6y0ni3jl190ddr6xfbi2rbcikqfm2ghzr53445fpk6g12zc")))) + "0vab3fbkps6kvkph38ssfk7s9j646l4gz8p1q4glvi26js0v9rhc")))) (build-system ruby-build-system) (arguments ; Tests don't require helper for some reason, so all fail... @@ -692,7 +665,7 @@ (description "WEBrick is an HTTP server toolkit that can be configured as an HTTPS server, a proxy server, and a virtual-host server.") (home-page "https://github.com/ruby/webrick") - (license (list #f #f)))) + (license #f))) (define-public ruby-interception (package @@ -772,6 +745,132 @@ (home-page "https://github.com/rantly-rb/rantly") (license license:expat))) +(define-public ruby-em-promise.rb + (package + (name "ruby-em-promise.rb") + (version "0.0.5") + (source (origin + (method url-fetch) + (uri (rubygems-uri "em_promise.rb" version)) + (sha256 + (base32 + "00rkmacyf6i2gq0giaxzwr24ygrwwzndrq7kkbpqcq9glk01w692")))) + (build-system ruby-build-system) + (arguments + ;; No rakefile + `(#:tests? #f)) + (propagated-inputs (list ruby-eventmachine ruby-promise.rb)) + (synopsis "A subclass of promise.rb Promise for EventMachine.") + (description + "This package provides a subclass of promise.rb Promise for EventMachine.") + (home-page "https://git.singpolyma.net/em_promise.rb") + (license #f))) + +(define-public ruby-em-socksify + (package + (name "ruby-em-socksify") + (version "0.3.2") + (source (origin + (method url-fetch) + (uri (rubygems-uri "em-socksify" version)) + (sha256 + (base32 + "0rk43ywaanfrd8180d98287xv2pxyl7llj291cwy87g1s735d5nk")))) + (build-system ruby-build-system) + (arguments + ;; Tests are broken + `(#:tests? #f)) + (propagated-inputs (list ruby-eventmachine)) + (synopsis "Transparent proxy support for any EventMachine protocol") + (description "Transparent proxy support for any EventMachine protocol") + (home-page "https://github.com/igrigorik/em-socksify") + (license license:expat))) + +(define-public ruby-rspec-collection-matchers + (package + (name "ruby-rspec-collection-matchers") + (version "1.2.0") + (source (origin + (method url-fetch) + (uri (rubygems-uri "rspec-collection_matchers" version)) + (sha256 + (base32 + "1864xlxl7mi6mvjyp85a0gc10cyvpf6bj8lc86sf8737wlzn12ks")))) + (build-system ruby-build-system) + (arguments + ;; No rakefile + `(#:tests? #f)) + (propagated-inputs (list ruby-rspec-expectations)) + (synopsis + "Collection cardinality matchers, extracted from rspec-expectations") + (description + "Collection cardinality matchers, extracted from rspec-expectations") + (home-page "https://github.com/rspec/rspec-collection_matchers") + (license license:expat))) + +(define-public ruby-cookiejar + (package + (name "ruby-cookiejar") + (version "0.3.3") + (source (origin + (method url-fetch) + (uri (rubygems-uri "cookiejar" version)) + (sha256 + (base32 + "0q0kmbks9l3hl0wdq744hzy97ssq9dvlzywyqv9k9y1p3qc9va2a")))) + (build-system ruby-build-system) + (native-inputs + `(("ruby-rspec" ,ruby-rspec) + ("ruby-rspec-collection-matchers" ,ruby-rspec-collection-matchers) + ("ruby-yard" ,ruby-yard))) + (synopsis + "Allows for parsing and returning cookies in Ruby HTTP client code") + (description + "Allows for parsing and returning cookies in Ruby HTTP client code") + (home-page "http://alkaline-solutions.com") + (license #f))) + +(define-public ruby-em-http-request + (package + (name "ruby-em-http-request") + (version "1.1.7") + (source (origin + (method url-fetch) + (uri (rubygems-uri "em-http-request" version)) + (sha256 + (base32 + "1azx5rgm1zvx7391sfwcxzyccs46x495vb34ql2ch83f58mwgyqn")))) + (build-system ruby-build-system) + (arguments + ;; Tests need a whole other set of dependencies + `(#:tests? #f)) + (propagated-inputs (list ruby-addressable ruby-cookiejar ruby-em-socksify + ruby-eventmachine ruby-http-parser.rb)) + (synopsis "EventMachine based, async HTTP Request client") + (description "EventMachine based, async HTTP Request client") + (home-page "http://github.com/igrigorik/em-http-request") + (license license:expat))) + +(define-public ruby-em-synchrony + (package + (name "ruby-em-synchrony") + (version "1.0.6") + (source (origin + (method url-fetch) + (uri (rubygems-uri "em-synchrony" version)) + (sha256 + (base32 + "1jh6ygbcvapmarqiap79i6yl05bicldr2lnmc46w1fyrhjk70x3f")))) + (build-system ruby-build-system) + (arguments + ;; Tests need a whole other set of dependencies + `(#:tests? #f)) + (propagated-inputs (list ruby-eventmachine)) + (synopsis "Fiber aware EventMachine libraries") + (description "Fiber aware EventMachine libraries") + (home-page "http://github.com/igrigorik/em-synchrony") + (license license:expat))) + (define %source-dir (dirname (current-filename))) (define %git-dir (string-append %source-dir "/.git")) @@ -833,6 +932,9 @@ ("ruby-pg" ,ruby-pg) ("ruby-redis" ,ruby-redis) ("ruby-roda" ,ruby-roda) + ("ruby-em-promise.rb" ,ruby-em-promise.rb) + ("ruby-em-synchrony" ,ruby-em-synchrony) + ("ruby-em-http-request" ,ruby-em-http-request) ("ruby-bandwidth-iris" ,ruby-bandwidth-iris) ("ruby-sentry" ,ruby-sentry) ("ruby" ,ruby) ;; Normally ruby-build-system adds this for us From 2ac3560afa1cfd5ebceee2b5f38d4c8b9679f9e8 Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 23 Jan 2023 18:02:34 -0500 Subject: [PATCH 3/5] Add PubSub Helpers to BlatherNotify Since we think we may end up struturing a few scripts to push things into a PubSub node, I wanted to make that as easy as possible. So the way this works is that at startup we can give it a pubsub node and server, and then we can call `BlatherNotify.publish` from then on giving it the thing we want to publish and it'll just push it to the node we configured. Easy. But if we run into a situation where we want to publish to 2 or more different pubsub nodes in different situations, we can instead call `BlatherNotify.pubsub(node, server)` and it'll return us a non-global thing we can call publish on which will publish to that node specifically. --- lib/blather_notify.rb | 48 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/lib/blather_notify.rb b/lib/blather_notify.rb index 81deb18cf6dfd88bf1a691ce2607a0c45168f26d..309f9e59eb05c0fd614b68b64c556345cd9b9c3f 100644 --- a/lib/blather_notify.rb +++ b/lib/blather_notify.rb @@ -7,13 +7,45 @@ require "timeout" module BlatherNotify extend Blather::DSL + class PubSub + class Address + attr_reader :node, :server + + def initialize(node:, server:) + @node = node + @server = server + end + + def to_uri + "xmpp:#{@server}?;node=#{@node}" + end + end + + def initialize(blather, addr) + @blather = blather + @addr = addr + end + + def publish(xml) + @blather.write_with_promise( + Blather::Stanza::PubSub::Publish.new( + @addr.server, + @addr.node, + :set, + xml + ) + ) + end + end + @ready = Queue.new when_ready { @ready << :ready } - def self.start(jid, password) + def self.start(jid, password, default_pubsub_addr: nil) # workqueue_count MUST be 0 or else Blather uses threads! setup(jid, password, nil, nil, nil, nil, workqueue_count: 0) + set_default_pubsub(default_pubsub_addr) EM.error_handler(&method(:panic)) @@ -81,4 +113,18 @@ module BlatherNotify write_with_promise(command(command_node, iq.sessionid, form: form)) end end + + def self.pubsub(addr) + PubSub.new(self, addr) + end + + def self.set_default_pubsub(addr) + @default_pubsub = addr && pubsub(addr) + end + + def self.publish(xml) + raise "No default pubsub set!" unless @default_pubsub + + @default_pubsub.publish(xml) + end end From 1128cbd906e3c81cff6ebda89ea50685c2b2ae9d Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 23 Jan 2023 18:02:35 -0500 Subject: [PATCH 4/5] Allow Running BlatherNotify in Reactor All of the other scripts spin up the reactor in another thread, and then they do stuff on this thread and use that reactor to have things happen, so they are happy to have BlatherNotify handle the reactor. The script I have coming, though, will instead use EM in the course of it's operation, so it's beneficial that I be able to spin up BlatherNotify in my reactor which allows me to block until the process is done there. --- lib/blather_notify.rb | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/blather_notify.rb b/lib/blather_notify.rb index 309f9e59eb05c0fd614b68b64c556345cd9b9c3f..6c5716cd3655cd28e94184818cd0c611efbe7e83 100644 --- a/lib/blather_notify.rb +++ b/lib/blather_notify.rb @@ -49,14 +49,22 @@ module BlatherNotify EM.error_handler(&method(:panic)) - @thread = Thread.new { - EM.run do - client.run - end - } + EM.next_tick { client.run } - Timeout.timeout(30) { @ready.pop } - at_exit { wait_then_exit } + block_until_ready + end + + def self.block_until_ready + if EM.reactor_running? + promise = EMPromise.new + disconnected { true.tap { EM.next_tick { EM.stop } } } + Thread.new { promise.fulfill(@ready.pop) } + timeout_promise(promise, timeout: 30) + else + @thread = Thread.new { EM.run } + Timeout.timeout(30) { @ready.pop } + at_exit { wait_then_exit } + end end def self.panic(e) @@ -69,11 +77,11 @@ module BlatherNotify disconnected { EM.stop } EM.add_timer(30) { EM.stop } shutdown - @thread.join + @thread&.join end def self.timeout_promise(promise, timeout: 15) - timer = EM.add_timer(timeout) { + timer = EventMachine::Timer.new(timeout) { promise.reject(:timeout) } From 507a4094d853b39bdb59841288865c4d2e66caff Mon Sep 17 00:00:00 2001 From: Christopher Vollick <0@psycoti.ca> Date: Mon, 23 Jan 2023 18:02:36 -0500 Subject: [PATCH 5/5] Interac Email Processor This script is expected to be run by piping an email into it and also giving a dhall config as the first argument. This will contain the JID and password to connect as, and the pubsub_node to dump the outcome to. It doesn't write anything directly, it just produces Atom into the pubsub channel, and the expectation is that some other process will do something with it; either display the message or the actual handling of the transaction. The Email parsing is intentionally very defensive, because the expectation is that whatever passes this parsing gets turned into credit in a user's account, so we want to make sure it's all above-board and bail early if something looks off. It's better to have to manually do something than to have it do too much on its own. We've tried to integrate the transaction values into atom as best as we can, and we've pulled in schema.org for the few things that didn't have a correlation. --- Gemfile | 1 + bin/process_interac_email | 62 ++++++++ jmp-pay.scm | 1 + lib/interac_email.rb | 300 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 364 insertions(+) create mode 100755 bin/process_interac_email create mode 100644 lib/interac_email.rb diff --git a/Gemfile b/Gemfile index ca692e20b542b76b5f530430e3e4d4765de0bc2b..27a54190605efab798e8d8b5b2b07fdd62735d62 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gem "dhall", ">= 0.5.3.fixed" gem "em-http-request" gem "em_promise.rb" gem "em-synchrony" +gem "mail" gem "money-open-exchange-rates" gem "pg" gem "redis" diff --git a/bin/process_interac_email b/bin/process_interac_email new file mode 100755 index 0000000000000000000000000000000000000000..02d087149cdd819554b0862a8ad17d87425c1bba --- /dev/null +++ b/bin/process_interac_email @@ -0,0 +1,62 @@ +#!/usr/bin/ruby +# frozen_string_literal: true + +# This expects postfix to be piping an email into stdin + +require "dhall" +require "mail" +require "nokogiri" +require "securerandom" +require "sentry-ruby" +require "time" + +require_relative "../lib/blather_notify" +require_relative "../lib/interac_email" + +def error_entry(title, text, id) + Nokogiri::XML::Builder.new { |xml| + xml.entry(xmlns: "http://www.w3.org/2005/Atom") do + xml.updated DateTime.now.iso8601 + xml.id id + xml.title title + xml.content text.to_s, type: "text" + xml.author { xml.name "interac_email" } + xml.generator "interac_email", version: "1.0" + end + }.doc.root +end + +raise "Need a Dhall config" unless ARGV[0] + +# I shift here because ARGF will work with either stdin or a file in argv if +# there are any +CONFIG = + Dhall::Coder + .new(safe: Dhall::Coder::JSON_LIKE + [Symbol, Proc]) + .load(ARGV.shift, transform_keys: :to_sym) + +pubsub = BlatherNotify::PubSub::Address.new(**CONFIG[:pubsub]) + +m = Mail.new(ARGF.read) +id_builder = ->(id) { "#{pubsub.to_uri};item=#{id}" } + +EM.run do + BlatherNotify.start( + CONFIG[:jid], + CONFIG[:password], + default_pubsub_addr: pubsub + ).then { + InteracEmail.for(m, id_builder: id_builder).process + }.catch_only(InteracEmail::Error) { |e| + uuid = SecureRandom.uuid + BlatherNotify.publish "#{uuid}": + error_entry("💥 Exception #{e.class}", e, id_builder.call(uuid)) + }.catch { |e| + if e.is_a?(::Exception) + Sentry.capture_exception(e) + else + Sentry.capture_message(e.to_s) + end + puts e + }.then { BlatherNotify.shutdown } +end diff --git a/jmp-pay.scm b/jmp-pay.scm index 901dde01081cdbd53ba9bdfc90f0ed54a46d6374..43f3085812580deca3d0e53d9e769ee809609636 100644 --- a/jmp-pay.scm +++ b/jmp-pay.scm @@ -936,6 +936,7 @@ ("ruby-em-synchrony" ,ruby-em-synchrony) ("ruby-em-http-request" ,ruby-em-http-request) ("ruby-bandwidth-iris" ,ruby-bandwidth-iris) + ("ruby-mail" ,ruby-mail) ("ruby-sentry" ,ruby-sentry) ("ruby" ,ruby) ;; Normally ruby-build-system adds this for us ("ruby-slim" ,ruby-slim))) diff --git a/lib/interac_email.rb b/lib/interac_email.rb new file mode 100644 index 0000000000000000000000000000000000000000..b7212529f9295b2186fc924ce30c4afcf011fcf4 --- /dev/null +++ b/lib/interac_email.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require "bigdecimal/util" +require "nokogiri" + +class InteracEmail + class Error < StandardError + def self.err(str=nil, &block) + Class.new(self).tap do |klass| + klass.define_method("initialize") do |m| + super(block ? block.call(m) : str) + end + end + end + + # The `m` in these contexts is the raw mail from the library + NoFrom = err "No 'From' (probably isn't an email)" + MultipleFrom = err { |m| "More than 1 'From' #{m.from}" } + BadSender = err "Email isn't from Interac" + + NoSpam = err "No Spam Status" + BadSPF = err "Don't trust SPF" + BadDKIM = err "Don't trust DKIM" + NoDKIM = err "No DKIM Signature somehow..." + WrongDKIM = err "DKIM Signature is for a different domain" + + # From here, the m in the error is assumed to be an instance rather than + # the underlying email object + + NoTxnID = err "No Transaction ID" + NoTxt = err "No text part" + BadParagraphs = err "Paragraph structure seems off" + NoMoney = err { |auto| + "Couldn't find money in \"#{auto.paragraphs[1]}\"" + } + BadMoney = err { |auto| "Dollars aren't dollars (#{auto.raw_dollars})" } + + NoJID = err { |auto| + "No JID in $%0.2f transfer %s from %s with message: %s" % + [ + auto.dollars, + auto.transaction_id, + auto.sender_name, + auto.message + ] + } + + MultipleJID = err { |auto| + "Multiple JIDs in $%0.2f transfer %s from %s with message: %s" % + [ + auto.dollars, + auto.transaction_id, + auto.sender_name, + auto.message + ] + } + end + + AUTO_REGEX = + /A +money +transfer +from .* has +been +automatically +deposited.$/ + .freeze + + def self.for(m, id_builder: ->(id) { id.to_s }) + Validator.new(m).validate! + (m.subject =~ AUTO_REGEX ? AutomaticEmail : ManualEmail) + .new(m, id_builder) + end + + def initialize(m, id_builder) + @m = m + @id_builder = id_builder + end + + class Validator + INTERAC_SENDERS = [ + "notify@payments.interac.ca", + "catch@payments.interac.ca" + ].freeze + + def initialize(m) + @m = m + end + + def validate! + ensure_relevant + ensure_safe + end + + def ensure_relevant + raise Error::NoFrom, @m unless @m.from + raise Error::MultipleFrom, @m unless @m.from.length == 1 + raise Error::BadSender, @m \ + unless INTERAC_SENDERS.include?(@m.from.first) + end + + def ensure_safe + ensure_spam_checks + ensure_dkim + end + + def spam_header + @m["X-Spam-Status"] + &.value + &.match(/tests=([^ ]*) /) + &.[](1) + &.split(/[,\t]+/) + end + + def ensure_spam_checks + spam = spam_header + + raise Error::NoSpam, @m unless spam + raise Error::BadSPF, @m unless spam.include?("SPF_PASS") + raise Error::BadDKIM, @m unless spam.include?("DKIM_VALID_AU") + end + + def dkim_header + @m["DKIM-Signature"] + &.value + &.split(/;\s*/) + &.each_with_object({}) { |f, h| + k, v = f.split("=", 2) + h[k.to_sym] = v + } + end + + def ensure_dkim + dkim = dkim_header + + raise Error::DKIM, @m unless dkim + raise Error::WrongDKIM, @m unless dkim[:d] == "payments.interac.ca" + end + end + + def sender_name + @m["From"].display_names.first + end + + def transaction_id + @m["X-PaymentKey"]&.value.tap { |v| + raise Error::NoTxnID, self unless v + } + end + + def text_part + @m.text_part.tap { |v| raise Error::NoText, self unless v } + end + + # First one is "Hi WHOEVER" + # Second one is "So and so sent you this much" + # Third is the message + # Fourth is Reference number + # Fifth is "Do not reply" + # Sixth is footer + def paragraphs + # This needs to be a non-capturing group "(?:" + # Split does a neat thing with groups where it puts + # the matching groups into the returned list! + # Neat, but absolutely not what I want + text_part.decoded.split(/(?:\r\n){2,}/).tap { |v| + # I don't really use the others, but make sure the + # first three are there + raise Error::BadParagraphs, self unless v.length > 3 + } + end + + def message + paragraphs[2].sub(/^Message:\s*/, "") + end + + # We don't want people's names to be able to spoof a number, + # so consume greedily from the start + DOLLARS_REGEX = / + # This is a free-spaced regex, so literal spaces + # don't count as spaces in match + .*\s+ has\s+ sent\s+ you\s+ a\s+ money\s+ transfer\s+ for\s+ the\s+ + amount\s+ of\s+ \$([^ ]*)\s+ \(CAD\)\s+ and\s+ the\s+ money\s+ has\s+ + been\s+ automatically\s+ deposited\s+ into\s+ your\s+ bank\s+ account + /x.freeze + + def raw_dollars + paragraphs[1].match(DOLLARS_REGEX)&.[](1).tap { |v| + raise Error::NoMoney, self unless v + } + end + + def dollars + raw_dollars.delete(",").to_d.tap { |v| + raise Error::BadMoney, self unless v.positive? + } + end + + def xmpp_id + @id_builder.call(transaction_id) + end + + def author_xml(xml) + xml.name sender_name + end + + def build_xml(xml) + xml.updated @m.date.iso8601 + xml.id xmpp_id + xml.generator "interac_email", version: "1.0" + xml.author do + author_xml(xml) + end + xml.price ("%0.4f" % dollars), xmlns: "https://schema.org" + xml.priceCurrency "CAD", xmlns: "https://schema.org" + xml.content to_s, type: "text" + end + + def to_xml + Nokogiri::XML::Builder.new { |xml| + xml.entry(xmlns: "http://www.w3.org/2005/Atom") do + build_xml(xml) + end + }.doc.root + end + + def process + BlatherNotify.publish "#{transaction_id}": to_xml + end + + class AutomaticEmail < self + def jids + message.scan(/[a-zA-Z0-9_.-]+@[a-zA-Z0-9_.-]+/).tap { |v| + raise Error::NoJID, self if v.empty? + } + end + + def jid + raise Error::MultipleJID, self unless jids.length == 1 + + jids.first.downcase + end + + def to_s + "$%0.2f received for %s" % [ + dollars, + jid + ] + end + + def author_xml(xml) + super + xml.uri "xmpp:#{jid}" + end + + def build_xml(xml) + super + xml.title "💸 Interac E-Transfer #{transaction_id}" + xml.category term: "interac_automatic", label: "Automatic Interac" + end + end + + class ManualEmail < self + # We don't want people's names to be able to spoof a number, + # so consume greedily from the start + REGEX = / + # This is a free-spaced regex, so literal spaces + # don't count as spaces in match + .*\s+ sent\s+ you\s+ a\s+ money\s+ transfer\s+ for\s+ the\s+ + amount\s+ of\s+ \$([^ ]*)\s+ \(CAD\). + /x.freeze + + def raw_dollars + paragraphs[1].match(REGEX)&.[](1).tap { |v| + raise Error::NoMoney, self unless v + } + end + + def raw_link + paragraphs[4]&.delete_prefix( + "To deposit your money, click here:\r\n" + ) + end + + def link + raw_link || "Couldn't find link" + end + + def to_s + "A manual E-Transfer has been received from \"%s\" for "\ + "$%0.2f. Message: %s\nTo deposit go to %s" % [ + sender_name, + dollars, + message, + link + ] + end + + def build_xml(xml) + super + xml.title "⚠️ Manual #{transaction_id}" + xml.link href: raw_link, type: "text/html" if raw_link + xml.category term: "interac_manual", label: "Manual Interac" + end + end +end