class Bank
Constants
- ACCOUNT_KEY
- ENCRYPTION_CIPHER
- ENCRYPTION_ENV_KEY
- ENCRYPTION_INFO
- ENCRYPTION_VERSION
- IRC_RESET
- LEDGER_KEY
- MAX_LEDGER_ENTRIES
- MAX_TRANSACTION_CENTS
- OPENING_BONUS_CENTS
- PLUGIN
- STYLE_TITLE
- STYLE_VALUE
Public Class Methods
Source
# File plugins/bank.rb, line 314 def self.account_open?(network:, nick:, store: RabbotDb) !load_account(network: network, nick: nick, store: store).nil? end
Source
# File plugins/bank.rb, line 301 def self.append_ledger(network:, nick:, entry:, store: RabbotDb) ledger = load_ledger(network: network, nick: nick, store: store) ledger << entry.merge("at" => RabbotDb.iso_time) save_ledger(network: network, nick: nick, ledger: ledger, store: store) end
Source
# File plugins/bank.rb, line 307 def self.balance_for(network:, nick:, store: RabbotDb) account = load_account(network: network, nick: nick, store: store) return nil unless account.is_a?(Hash) account["balance_cents"].to_i end
Source
# File plugins/bank.rb, line 39 def self.command_pattern /bank(?:\s+(.+))?$/i end
Source
# File plugins/bank.rb, line 179 def self.decode_plugin_payload(raw) text = raw.to_s return nil if text.strip.empty? return decrypt_json(text) if encrypted_envelope?(text) JSON.parse(text) rescue JSON::ParserError nil rescue DecryptionError raise VaultLockedError end
Source
# File plugins/bank.rb, line 162 def self.decrypt_json(raw, secret: encryption_secret) envelope = JSON.parse(raw.to_s) unless encrypted_envelope?(envelope) raise DecryptionError, "Bank payload is not encrypted." end cipher = OpenSSL::Cipher.new(ENCRYPTION_CIPHER) cipher.decrypt cipher.key = derive_encryption_key(secret) cipher.iv = envelope["n"].unpack1("m0") cipher.auth_tag = envelope["t"].unpack1("m0") plaintext = cipher.update(envelope["d"].unpack1("m0")) + cipher.final JSON.parse(plaintext) rescue OpenSSL::Cipher::CipherError, JSON::ParserError raise DecryptionError, "Bank payload could not be decrypted." end
Source
# File plugins/bank.rb, line 354 def self.deposit(network:, nick:, amount_cents:, store: RabbotDb) account = require_account(network: network, nick: nick, store: store) return account if account.is_a?(Hash) && account[:success] == false new_balance = account["balance_cents"].to_i + amount_cents account["balance_cents"] = new_balance save_account(network: network, nick: nick, account: account, store: store) append_ledger( network: network, nick: nick, entry: { "type" => "deposit", "amount_cents" => amount_cents, "balance_after" => new_balance }, store: store ) { success: true, balance_cents: new_balance, amount_cents: amount_cents } rescue VaultLockedError vault_locked_result end
Source
# File plugins/bank.rb, line 121 def self.derive_encryption_key(secret = encryption_secret) OpenSSL::KDF.hkdf( Digest::SHA256.digest(secret), salt: "", info: ENCRYPTION_INFO, length: 32, hash: OpenSSL::Digest::SHA256.new ) end
Source
# File plugins/bank.rb, line 605 def self.dispatch(parsed:, network:, nick:, store: RabbotDb) return format_error(parsed[:error]) if parsed[:error] case parsed[:action] when :help format_help when :paperwork format_paperwork when :open result = open_account(network: network, nick: nick, store: store) return format_quantum_vault_locked if vault_locked_response?(result) return format_error(result[:error]) unless result[:success] format_open_success(nick: nick, account: result[:account]) when :balance format_balance_report(network: network, nick: nick, store: store) when :statement format_statement(network: network, nick: nick, store: store) when :deposit result = deposit(network: network, nick: nick, amount_cents: parsed[:amount_cents], store: store) return format_quantum_vault_locked if vault_locked_response?(result) return format_error(result[:error]) unless result[:success] format_deposit_success(amount_cents: result[:amount_cents], balance_cents: result[:balance_cents]) when :withdraw result = withdraw(network: network, nick: nick, amount_cents: parsed[:amount_cents], store: store) return format_quantum_vault_locked if vault_locked_response?(result) return format_error(result[:error]) unless result[:success] format_withdraw_success(amount_cents: result[:amount_cents], balance_cents: result[:balance_cents]) when :transfer result = transfer( network: network, from: nick, to: parsed[:target], amount_cents: parsed[:amount_cents], store: store ) return format_quantum_vault_locked if vault_locked_response?(result) return format_error(result[:error]) unless result[:success] format_transfer_success( target: result[:target], amount_cents: result[:amount_cents], balance_cents: result[:from_balance_cents] ) else format_help end rescue VaultLockedError format_quantum_vault_locked end
Source
# File plugins/bank.rb, line 145 def self.encrypt_json(value, secret: encryption_secret) plaintext = JSON.generate(value) cipher = OpenSSL::Cipher.new(ENCRYPTION_CIPHER) cipher.encrypt cipher.key = derive_encryption_key(secret) iv = cipher.random_iv ciphertext = cipher.update(plaintext) + cipher.final envelope = { "v" => ENCRYPTION_VERSION, "alg" => ENCRYPTION_CIPHER, "n" => [iv].pack("m0"), "d" => [ciphertext].pack("m0"), "t" => [cipher.auth_tag].pack("m0") } JSON.generate(envelope) end
Source
# File plugins/bank.rb, line 131 def self.encrypted_envelope?(raw) return false if raw.nil? payload = raw.is_a?(Hash) ? raw : JSON.parse(raw.to_s) payload.is_a?(Hash) && payload["v"] == ENCRYPTION_VERSION && payload["alg"] == ENCRYPTION_CIPHER && payload["n"].is_a?(String) && payload["d"].is_a?(String) && payload["t"].is_a?(String) rescue JSON::ParserError false end
Source
# File plugins/bank.rb, line 114 def self.encryption_secret secret = ENV[ENCRYPTION_ENV_KEY].to_s.strip raise DecryptionError, "Bank encryption key is not configured." if secret.empty? secret end
Source
# File plugins/bank.rb, line 483 def self.format_balance_report(network:, nick:, store: RabbotDb) account = load_account(network: network, nick: nick, store: store) return format_no_account(nick) unless account.is_a?(Hash) balance = account["balance_cents"].to_i header = IrcFormat.report_header( tag: "BANK", title: "acct #{account['account_number']} ยท #{Normalize.nick(nick)}", style: STYLE_TITLE ) body = "Available balance: #{format_money(balance)}" footer = IrcFormat.report_footer("Rabbot Savings & Trust") [header, body, footer].join("\n") rescue VaultLockedError format_quantum_vault_locked end
Source
# File plugins/bank.rb, line 511 def self.format_deposit_success(amount_cents:, balance_cents:) header = IrcFormat.report_header(tag: "BANK", title: "deposit accepted", style: STYLE_TITLE) body = [ "Deposited #{format_money(amount_cents)}.", "Available balance: #{format_money(balance_cents)}" ].join("\n") footer = IrcFormat.report_footer("Form RAB-003 not required for deposits") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 584 def self.format_error(message) header = IrcFormat.report_header(tag: "BANK", title: "request declined", style: STYLE_TITLE) body = message.to_s footer = IrcFormat.report_footer("Rabbot Savings & Trust") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 591 def self.format_help header = IrcFormat.report_header(tag: "BANK", title: "Rabbot Savings & Trust", style: STYLE_TITLE) body = [ usage_message, "!bank paperwork โ forms for new accounts and transfers" ].join("\n") footer = IrcFormat.report_footer("member FDIC (Fictional IRC Deposit Corporation)") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 541 def self.format_ledger_line(entry) amount = format_money(entry["amount_cents"].to_i) case entry["type"] when "opening_bonus" "Opening bonus #{amount}" when "deposit" "Deposit #{amount}" when "withdraw" "Withdraw #{amount}" when "transfer_out" "Transfer to #{entry['counterparty']} #{amount}" when "transfer_in" "Transfer from #{entry['counterparty']} #{amount}" else "#{entry['type']} #{amount}" end end
Source
# File plugins/bank.rb, line 63 def self.format_money(cents) value = cents.to_i sign = value.negative? ? "-" : "" abs = value.abs "$#{sign}#{'%.2f' % (abs / 100.0)}" end
Source
# File plugins/bank.rb, line 473 def self.format_no_account(nick) header = IrcFormat.report_header(tag: "BANK", title: "no account on file", style: STYLE_TITLE) body = [ "#{Normalize.nick(nick)}, you are not a customer yet.", "Start with !bank paperwork then !bank open." ].join("\n") footer = IrcFormat.report_footer("Rabbot Savings & Trust") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 500 def self.format_open_success(nick:, account:) header = IrcFormat.report_header(tag: "BANK", title: "account opened", style: STYLE_TITLE) body = [ "Welcome, #{Normalize.nick(nick)}.", "Account #{account['account_number']} is active.", "Opening deposit: #{format_money(account['balance_cents'].to_i)}" ].join("\n") footer = IrcFormat.report_footer("forms RAB-001 accepted") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 458 def self.format_paperwork header = IrcFormat.report_header(tag: "BANK", title: "new account paperwork", style: STYLE_TITLE) body = [ "Rabbot Savings & Trust โ account opening package", "", "Form RAB-001 Account Application โ sign with !bank open", "Form RAB-002 Wire Transfer Authorization โ included with RAB-001; needed for !bank transfer", "Form RAB-003 Withdrawal Slip โ submit each time via !bank withdraw <amount>", "", "Bring your IRC nick as legal ID. Branch hours: 24/7." ].join("\n") footer = IrcFormat.report_footer("paperwork", "Rabbot Savings & Trust") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 201 def self.format_quantum_vault_locked header = IrcFormat.report_header( tag: "BANK", title: "quantum vault sealed", style: STYLE_TITLE ) body = [ "Quantum decryption shields blocked access to encrypted account data.", "No balances or ledger entries were exposed.", "Branch operator must restore the vault key before service resumes." ].join("\n") footer = IrcFormat.report_footer("quantum decryption protection") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 559 def self.format_statement(network:, nick:, store: RabbotDb) account = load_account(network: network, nick: nick, store: store) return format_no_account(nick) unless account.is_a?(Hash) ledger = load_ledger(network: network, nick: nick, store: store) header = IrcFormat.report_header( tag: "BANK", title: "statement ยท acct #{account['account_number']}", style: STYLE_TITLE ) lines = if ledger.empty? ["No recent activity."] else ledger.last(MAX_LEDGER_ENTRIES).map { |entry| format_ledger_line(entry) } end body = lines.join("\n") footer = IrcFormat.report_footer( "balance #{format_money(account['balance_cents'].to_i)}", "#{ledger.length} entries" ) [header, body, footer].join("\n") rescue VaultLockedError format_quantum_vault_locked end
Source
# File plugins/bank.rb, line 531 def self.format_transfer_success(target:, amount_cents:, balance_cents:) header = IrcFormat.report_header(tag: "BANK", title: "transfer sent", style: STYLE_TITLE) body = [ "Sent #{format_money(amount_cents)} to #{Normalize.nick(target)}.", "Available balance: #{format_money(balance_cents)}" ].join("\n") footer = IrcFormat.report_footer("Form RAB-002 on file") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 521 def self.format_withdraw_success(amount_cents:, balance_cents:) header = IrcFormat.report_header(tag: "BANK", title: "withdrawal processed", style: STYLE_TITLE) body = [ "Withdrawn #{format_money(amount_cents)}.", "Available balance: #{format_money(balance_cents)}" ].join("\n") footer = IrcFormat.report_footer("Form RAB-003 filed") [header, body, footer].join("\n") end
Source
# File plugins/bank.rb, line 109 def self.generate_account_number(nick, opened_at) seed = "#{Normalize.nick(nick)}:#{opened_at}" format("%08d", Digest::SHA256.hexdigest(seed)[0, 8].to_i(16) % 100_000_000) end
Source
# File plugins/bank.rb, line 191 def self.guard_vault yield rescue DecryptionError raise VaultLockedError end
Source
# File plugins/bank.rb, line 255 def self.load_account(network:, nick:, store: RabbotDb) load_plugin_payload( plugin: PLUGIN, key: ACCOUNT_KEY, network: network, nick: nick, store: store, default: nil ) end
Source
# File plugins/bank.rb, line 277 def self.load_ledger(network:, nick:, store: RabbotDb) Array( load_plugin_payload( plugin: PLUGIN, key: LEDGER_KEY, network: network, nick: nick, store: store, default: [] ) ) end
Source
# File plugins/bank.rb, line 216 def self.load_plugin_payload(plugin:, key:, network:, nick:, store: RabbotDb, default: nil) guard_vault do raw = store.get_plugin_value( plugin: plugin, key: key, network: network, nick: nick ) return default if raw.nil? || raw.to_s.strip.empty? payload = decode_plugin_payload(raw) return default if payload.nil? if !encrypted_envelope?(raw.to_s) save_plugin_payload( plugin: plugin, key: key, network: network, nick: nick, value: payload, store: store ) end payload end end
Source
# File plugins/bank.rb, line 318 def self.open_account(network:, nick:, store: RabbotDb) if account_open?(network: network, nick: nick, store: store) return { success: false, error: "You already have a Rabbot Savings & Trust account." } end opened_at = RabbotDb.iso_time account = { "account_number" => generate_account_number(nick, opened_at), "opened_at" => opened_at, "balance_cents" => OPENING_BONUS_CENTS } save_account(network: network, nick: nick, account: account, store: store) append_ledger( network: network, nick: nick, entry: { "type" => "opening_bonus", "amount_cents" => OPENING_BONUS_CENTS, "balance_after" => OPENING_BONUS_CENTS }, store: store ) { success: true, account: account } rescue VaultLockedError vault_locked_result end
Source
# File plugins/bank.rb, line 49 def self.parse_amount_cents(text) raw = text.to_s.strip return nil unless raw.match?(/\A\d+(?:\.\d{1,2})?\z/) dollars, cents = raw.split(".", 2) whole = dollars.to_i fraction = cents.to_s.ljust(2, "0")[0, 2].to_i total = (whole * 100) + fraction return nil if total <= 0 return nil if total > MAX_TRANSACTION_CENTS total end
Source
# File plugins/bank.rb, line 70 def self.parse_command(text, nick: nil) stripped = text.to_s.strip return { action: :balance } if stripped.empty? case stripped when /\A(?:help|\?)\z/i { action: :help } when /\A(?:paperwork|forms|paper)\z/i { action: :paperwork } when /\Aopen\z/i { action: :open } when /\Abalance\z/i { action: :balance } when /\Astatement\z/i { action: :statement } when /\Adeposit\s+(.+)\z/i amount_cents = parse_amount_cents(Regexp.last_match(1)) return { error: "Enter a positive amount, e.g. !bank deposit 25 or !bank deposit 10.50" } unless amount_cents { action: :deposit, amount_cents: amount_cents } when /\Awithdraw\s+(.+)\z/i amount_cents = parse_amount_cents(Regexp.last_match(1)) return { error: "Enter a positive amount, e.g. !bank withdraw 25" } unless amount_cents { action: :withdraw, amount_cents: amount_cents } when /\Atransfer\s+(\S+)\s+(.+)\z/i target = Normalize.nick(Regexp.last_match(1)) amount_cents = parse_amount_cents(Regexp.last_match(2)) return { error: "Enter a positive amount, e.g. !bank transfer MeatAnt 25" } unless amount_cents if nick && Normalize.user_key(target) == Normalize.user_key(nick) return { error: "You cannot transfer funds to yourself." } end { action: :transfer, target: target, amount_cents: amount_cents } else { error: usage_message } end end
Source
# File plugins/bank.rb, line 345 def self.require_account(network:, nick:, store: RabbotDb) account = load_account(network: network, nick: nick, store: store) return account if account.is_a?(Hash) { success: false, error: "No account on file โ complete Form RAB-001 with !bank paperwork then !bank open" } rescue VaultLockedError vault_locked_result end
Source
# File plugins/bank.rb, line 266 def self.save_account(network:, nick:, account:, store: RabbotDb) save_plugin_payload( plugin: PLUGIN, key: ACCOUNT_KEY, network: network, nick: nick, value: account, store: store ) end
Source
# File plugins/bank.rb, line 290 def self.save_ledger(network:, nick:, ledger:, store: RabbotDb) save_plugin_payload( plugin: PLUGIN, key: LEDGER_KEY, network: network, nick: nick, value: ledger.last(MAX_LEDGER_ENTRIES), store: store ) end
Source
# File plugins/bank.rb, line 243 def self.save_plugin_payload(plugin:, key:, value:, network:, nick:, store: RabbotDb) guard_vault do store.set_plugin_value( plugin: plugin, key: key, network: network, nick: nick, value: encrypt_json(value) ) end end
Source
# File plugins/bank.rb, line 403 def self.transfer(network:, from:, to:, amount_cents:, store: RabbotDb) from_account = require_account(network: network, nick: from, store: store) return from_account if from_account.is_a?(Hash) && from_account[:success] == false to_account = require_account(network: network, nick: to, store: store) if to_account.is_a?(Hash) && to_account[:success] == false return { success: false, error: "Recipient has no account โ they need !bank open first." } end return { success: false, error: "You cannot transfer funds to yourself." } if Normalize.user_key(from) == Normalize.user_key(to) from_balance = from_account["balance_cents"].to_i if amount_cents > from_balance return { success: false, error: "Insufficient funds (balance: #{format_money(from_balance)})." } end to_balance = to_account["balance_cents"].to_i new_from_balance = from_balance - amount_cents new_to_balance = to_balance + amount_cents from_account["balance_cents"] = new_from_balance to_account["balance_cents"] = new_to_balance save_account(network: network, nick: from, account: from_account, store: store) save_account(network: network, nick: to, account: to_account, store: store) append_ledger( network: network, nick: from, entry: { "type" => "transfer_out", "amount_cents" => amount_cents, "counterparty" => Normalize.nick(to), "balance_after" => new_from_balance }, store: store ) append_ledger( network: network, nick: to, entry: { "type" => "transfer_in", "amount_cents" => amount_cents, "counterparty" => Normalize.nick(from), "balance_after" => new_to_balance }, store: store ) { success: true, amount_cents: amount_cents, from_balance_cents: new_from_balance, to_balance_cents: new_to_balance, target: Normalize.nick(to) } rescue VaultLockedError vault_locked_result end
Source
# File plugins/bank.rb, line 45 def self.usage_message "Usage: !bank [open|balance|deposit <amt>|withdraw <amt>|transfer <nick> <amt>|paperwork|statement]" end
Source
# File plugins/bank.rb, line 601 def self.vault_locked_response?(result) result.is_a?(Hash) && result[:vault_locked] end
Source
# File plugins/bank.rb, line 197 def self.vault_locked_result { success: false, vault_locked: true } end
Source
# File plugins/bank.rb, line 376 def self.withdraw(network:, nick:, amount_cents:, store: RabbotDb) account = require_account(network: network, nick: nick, store: store) return account if account.is_a?(Hash) && account[:success] == false balance = account["balance_cents"].to_i if amount_cents > balance return { success: false, error: "Insufficient funds (balance: #{format_money(balance)})." } end new_balance = balance - amount_cents account["balance_cents"] = new_balance save_account(network: network, nick: nick, account: account, store: store) append_ledger( network: network, nick: nick, entry: { "type" => "withdraw", "amount_cents" => amount_cents, "balance_after" => new_balance }, store: store ) { success: true, balance_cents: new_balance, amount_cents: amount_cents } rescue VaultLockedError vault_locked_result end
Public Instance Methods
Source
# File plugins/bank.rb, line 658 def execute(m, args = nil) unless m.channel m.reply "Use !bank in a channel" return end parsed = self.class.parse_command(args, nick: m.user.nick) message = self.class.dispatch( parsed: parsed, network: bot.config.server, nick: m.user.nick ) themed_flood_safe_reply(m, message) end