~vesto/huginn_venmo_agent

f11a7a8c9fc46b3ea5d4a0cac946e21e8aa1d8f7 — Steve Gattuso 3 years ago
Initial commit
A  => Gemfile +6 -0
@@ 1,6 @@
source 'https://rubygems.org'

gemspec

gem 'huginn_agent'
gem 'http'

A  => README.md +2 -0
@@ 1,2 @@
# YNAB Agent
For sending transactions to YNAB from Huginn

A  => huginn_venmo_agent.gemspec +23 -0
@@ 1,23 @@
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)

Gem::Specification.new do |spec|
  spec.name          = 'huginn_venmo_agent'
  spec.version       = '0.1.0'
  spec.authors       = ['Steve Gattuso']
  spec.email         = ['steve@stevegattuso.me']

  spec.summary       = 'Huginn Agent to interact with the Venmo API'

  spec.homepage      = 'https://github.com/stevenleeg/huginn_venmo_agent'


  spec.files         = Dir['LICENSE.txt', 'lib/**/*']
  spec.require_paths = ['lib']

  spec.add_development_dependency 'bundler', '~> 1.7'
  spec.add_development_dependency 'rake', '>= 12.3.3'

  spec.add_runtime_dependency 'huginn_agent'
  spec.add_runtime_dependency 'http'
end

A  => lib/huginn_venmo_agent.rb +5 -0
@@ 1,5 @@
require 'huginn_agent'
require 'huginn_venmo_agent/railtie' if defined?(Rails)

HuginnAgent.register 'huginn_venmo_agent/venmo_timeline_agent'
HuginnAgent.register 'huginn_venmo_agent/venmo_request_agent'

A  => lib/huginn_venmo_agent/railtie.rb +13 -0
@@ 1,13 @@
require 'huginn_venmo_agent'
require 'rails'

module MyGem
  class Railtie < Rails::Railtie
    railtie_name :huginn_venmo_agent

    rake_tasks do
      path = File.expand_path(__dir__)
      Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f }
    end
  end
end

A  => lib/huginn_venmo_agent/tasks/venmo.rake +94 -0
@@ 1,94 @@
require 'huginn_venmo_agent'

namespace :venmo do
  API_BASE = 'https://api.venmo.com/v1'

  task search: :environment do
    print "Venmo auth token: "
    access_token = STDIN.gets.chomp

    print "Enter username: "
    query = STDIN.gets.chomp

    resp = HTTP
      .auth("Bearer #{access_token}")
      .get("#{API_BASE}/users", json: {
        query: query,
        limit: 5,
        type: 'username',
      })

    if resp.status < 200 || resp.status >= 300
      puts("Bad response from Venmo (#{resp.status}): #{resp.body.parse}")
      exit
    end

    puts resp.to_s
  end

  task authenticate: :environment do
    device_id = Agents::VenmoAgent.generate_device_id
    puts "Your device ID is #{device_id} (save this!)"
    print "Venmo Username: "
    username = STDIN.gets.chomp
    print "Venmo Password: "
    password = STDIN.gets.chomp

    puts("Requesting an auth token with the provided credentials...")
    resp = HTTP
      .headers('device-id' => device_id)
      .post("#{API_BASE}/oauth/access_token", json: {
        phone_email_or_username: username,
        client_id: '1',
        password: password,
      })

    if resp.code == 201
      json_resp = resp.body.parse
      puts("Your access token is #{json_resp['access_token']}")
      puts("Please keep this token safe! It grants access to pretty much all of Venmo's functionality (including sending money!!)")
    elsif resp.code == 400
      puts("Your username/password was incorrect. Aborting!")
    elsif resp.code == 401
      otp_secret = resp.headers['venmo-otp-secret']
      puts otp_token

      # Send off a text to their phone
      resp = HTTP
        .headers('device-id' => device_id, 'venmo-otp-secret' => otp_secret)
        .post("#{API_BASE}/account/two-factor/token", json: {
          phone_email_or_username: username,
          client_id: '1',
          password: password,
        })

      if resp.code == 201
        puts("Looks like you have 2FA enabled, please enter the auth code texted to your phone.")
        print "Auth code: "
        auth_code = STDIN.gets.chomp

        resp = HTTP
          .headers('device-id' => device_id, 'venmo-otp-secret' => otp_secret, 'Venmo-Otp' => auth_code)
          .post("#{API_BASE}/oauth/access_token?client_id=1", json: {
            phone_email_or_username: username,
            client_id: '1',
            password: password,
          })

        if resp.code == 201
          json_resp = resp.body.parse
          puts("Your access token is #{json_resp['access_token']}")
          puts("Please keep this token safe! It grants access to pretty much all of Venmo's functionality (including sending money!!)")
        else
          puts("Wonky response code from Venmo (#{resp.code}): #{resp.to_s}")
        end
      elsif resp.code == 400
        puts("Looks like your 2FA session expired. Try again.")
      else
        puts("Wonky response code from Venmo (#{resp.code}): #{resp.to_s}")
      end
    else
      puts("Wonky response code from Venmo (#{resp.code}): #{resp.to_s}")
    end
  end
end

A  => lib/huginn_venmo_agent/venmo_agent.rb +80 -0
@@ 1,80 @@
module Agents
  class VenmoAgent < Agent
    API_BASE = 'https://api.venmo.com/v1'

    include FormConfigurable

    can_dry_run!

    def working?
      true
    end

    def default_options
      {}
    end

    form_configurable :venmo_token
    def validate_options
      if options['venmo_token'].blank?
        errors.add(:base, 'Venmo access token is required')
      end
    end

    def default_options
      {
        'venmo_token' => '{% credential VENMO_TOKEN %}',
      }
    end

    def check
    end

    def receive(incoming_events)
      incoming_events.each do |event|
        if event['amount'] < 0
          error('Disallowing payment (this Agent is too new/dangerous for this to be enabled)')
          next
        elsif agent['user_id'].nil?
          error('Payment requires a user_id key in the calling event')
          next
        elsif agent['note'].nil?
          error('Payment requires a note key in the calling event')
          next
        end

        log("Requesting $#{event.payload['amount']} from #{event.payload['user_id']}")
        resp = HTTP
          .auth("Bearer #{options['venmo_token']}")
          .post("#{API_BASE}/payments", json: {
            note: event.payload['note'],
            amount: event.payload['amount'] * -1,
            metadata: {quasi_cash_disclaimer_viewed: false},
            user_id: event.payload['user_id'],
            audience: event.payload['audience'] || 'private',
          })

        if resp.status >= 200 && resp.status < 300
          log("Success!")
          create_event payload: resp.body.parse
        else
          error("Error creating payment (#{resp.status}): #{resp.body.parse}")
        end
      end
    end

    def self.generate_device_id
      random_string = '88884260-05O3-8U81-58I1-2WA76F357GR9'.split('').map do |char|
        if /^[0-9]$/.match?(char)
          (0..9).to_a.sample
        elsif char == '-'
          '-'
        else
          ('A'..'Z').to_a.sample
        end
      end

      random_string.join('')
    end
  end
end

A  => lib/huginn_venmo_agent/venmo_request_agent.rb +104 -0
@@ 1,104 @@
module Agents
  class VenmoRequestAgent < Agent
    API_BASE = 'https://api.venmo.com/v1'

    include FormConfigurable

    can_dry_run!
    cannot_be_scheduled!

    def working?
      true
    end

    def default_options
      {}
    end

    form_configurable :venmo_token
    def validate_options
      if options['venmo_token'].blank?
        errors.add(:base, 'Venmo access token is required')
      end
    end

    def default_options
      {
        'venmo_token' => '{% credential VENMO_TOKEN %}',
      }
    end

    def check
      memory['last_check'] = DateTime.now.to_i
      resp = HTTP
        .auth("Bearer #{interpolated['venmo_token']}")
        .get("#{API_BASE}/stories/target-or-actor/#{my_id}")

      log(resp.parse)
    end

    def receive(incoming_events)
      incoming_events.each do |event|
        if event.payload['amount'].nil? || event.payload['amount'] < 0
          error('Disallowing payment (this Agent is too new/dangerous for this to be enabled)')
          next
        elsif event.payload['user_id'].nil?
          error('Payment requires a user_id key in the calling event')
          next
        elsif event.payload['note'].nil?
          error('Payment requires a note key in the calling event')
          next
        end

        log("Requesting $#{event.payload['amount']} from #{event.payload['user_id']}")
        resp = HTTP
          .auth("Bearer #{interpolated['venmo_token']}")
          .post("#{API_BASE}/payments", json: {
            note: event.payload['note'],
            amount: event.payload['amount'] * -1,
            metadata: {quasi_cash_disclaimer_viewed: false},
            user_id: event.payload['user_id'],
            audience: event.payload['audience'] || 'private',
          })

        if resp.status >= 200 && resp.status < 300
          log("Success!")
          create_event payload: resp.parse
        else
          error("Error creating payment (#{resp.status}): #{resp.parse}")
        end
      end
    end

    def self.generate_device_id
      random_string = '88884260-05O3-8U81-58I1-2WA76F357GR9'.split('').map do |char|
        if /^[0-9]$/.match?(char)
          (0..9).to_a.sample
        elsif char == '-'
          '-'
        else
          ('A'..'Z').to_a.sample
        end
      end

      random_string.join('')
    end

    private

    # Returns the ID of the currently signed in user
    def my_id
      if !memory['me'].nil?
        return memory['me']
      end

      resp = HTTP
        .auth("Bearer #{interpolated['venmo_token']}")
        .get("#{API_BASE}/me")

      json_resp = resp.parse
      memory['me'] = resp['user']['id']
      return memory['me']
    end
  end
end

A  => lib/huginn_venmo_agent/venmo_timeline_agent.rb +73 -0
@@ 1,73 @@
module Agents
  class VenmoTimelineAgent < Agent
    API_BASE = 'https://api.venmo.com/v1'

    include FormConfigurable

    cannot_receive_events!
    can_dry_run!

    def working?
      true
    end

    def default_options
      {}
    end

    form_configurable :venmo_token
    def validate_options
      if options['venmo_token'].blank?
        errors.add(:base, 'Venmo access token is required')
      end
    end

    def default_options
      {
        'venmo_token' => '{% credential VENMO_TOKEN %}',
      }
    end

    def check
      memory['last_check'] = DateTime.now.to_i

      resp = HTTP
        .auth("Bearer #{interpolated['venmo_token']}")
        .get("#{API_BASE}/stories/target-or-actor/#{my_id}")

      if resp.status < 200 || resp.status >= 300
        error("Venmo gave a bad response (#{resp.status}): #{resp.to_s}")
        return
      end

      json_resp = resp.parse
      event_times = []
      json_resp['data'].each do |tx|
        next if memory['latest_tx_datetime'] && tx['date_updated'].to_time.to_i <= memory['latest_tx_datetime']
        event_times << tx['date_updated'].to_time.to_i
        create_event payload: tx
      end

      if event_times.count > 0
        memory['latest_tx_datetime'] = event_times.max
      end
    end

    private

    # Returns the ID of the currently signed in user
    def my_id
      if !memory['me'].nil?
        return memory['me']
      end

      resp = HTTP
        .auth("Bearer #{interpolated['venmo_token']}")
        .get("#{API_BASE}/me")

      json_resp = resp.parse
      memory['me'] = json_resp['data']['user']['id']
      return memory['me']
    end
  end
end