seansen
4/22/2018 - 4:23 AM

Dr. Phil (`drphil.rb`) is reimplementation of the "Winfrey" voting bot specification. The goal is to give everyone an upvote. See: https:/

Dr. Phil (drphil.rb) is reimplementation of the "Winfrey" voting bot specification. The goal is to give everyone an upvote. See: https://steemit.com/radiator/@inertia/drphil-rb-voting-bot-update-fixes

This is the Dr. Phil bot for STEEM and GOLOS.  Please have a look at the README.md file.
drphil.log
.rvmrc
favorite_accounts.txt                                                                                                                               │·
flag_signals.txt                                                                                                                                    │·
skip_accounts.txt                                                                                                                                   │·
skip_tags.txt                                                                                                                                       │·
vote_signals.txt                                                                                                                                    │·
voters.txt
source 'https://rubygems.org'
gem 'radiator'
GEM
  remote: https://rubygems.org/
  specs:
    awesome_print (1.8.0)
    bitcoin-ruby (0.0.17)
    connection_pool (2.2.1)
    ffi (1.9.21)
    hashie (3.5.7)
    json (2.1.0)
    little-plugger (1.1.4)
    logging (2.2.2)
      little-plugger (~> 1.1)
      multi_json (~> 1.10)
    multi_json (1.13.1)
    net-http-persistent (3.0.0)
      connection_pool (~> 2.2)
    radiator (0.3.15)
      awesome_print (~> 1.7, >= 1.7.0)
      bitcoin-ruby (~> 0.0, >= 0.0.11)
      ffi (~> 1.9, >= 1.9.18)
      hashie (~> 3.5, >= 3.5.5)
      json (~> 2.0, >= 2.0.2)
      logging (~> 2.2, >= 2.2.0)
      net-http-persistent (>= 2.5.2)

PLATFORMS
  ruby

DEPENDENCIES
  radiator

BUNDLED WITH
   1.16.1
  • Title: drphil.rb - Voting Bot - Mac Installation
  • Tags: radiator ruby steem steemdev curation
  • Notes:

Before you can use drphil, you will need to install bundler. If you are using the system version of ruby, you need to use sudo to install bundler:

$ sudo gem install bundler

On modern versions of macOS, attempting to use git will prompt its installation, which is a neat feature. Just type git in the terminal and hit enter. You'll get this dialog:

Then you can proceed to do the normal installation steps.

$ git clone https://gist.github.com/61bcc2b821aa5acb24f7fc88921950c7.git drphil
$ cd drphil
$ bundle install

Then run it:

$ ruby drphil.rb

To configure drphil, you will need to modify the drphil.yml. You can edit it by typing open -e drphil.yml in the terminal.

  • Title: drphil.rb - Voting Bot - Windows Installation
  • Tags: radiator ruby steem steemdev curation
  • Notes:

For these steps, we will install the cygwin package manager, which will provide the support packages and dependencies we need.

https://cygwin.com/

We need cygwin to install git, ruby-dev, gem, and make for us. Run its setup and take all of the defaults, clicking Next until you reach the Select Packages dialog. Select the View option of Full. Search for:

  • git and find the package named: git: Distributed version control. Change Skip to 2.12.2-1 (or later).
  • ruby-dev and find the package named: ruby-devel: Interpreted object-oriented scripting language. Change Skip to 2.3.3-1 (or later).
  • gem and find the package named: rubygem: Ruby module management. Change Skip to 2.3.3-1 (or later).
  • make and find the package named: make: The GNU version of the 'make' utility. Change Skip to 4.2.1-1 (or later).

Click Next until cygwin is done installing these packages.

Open the Cygwin Terminal

Now, we can continue with the usual install:

$ git clone https://gist.github.com/61bcc2b821aa5acb24f7fc88921950c7.git drphil
$ cd drphil
$ bundle install

Then run it:

$ ruby drphil.rb

To configure drphil, you will need to modify the drphil.yml which will be located in:

C:\cygwin\home\<username>\drphil


If you're using Windows XP, official cygwin support has been dropped. There is an unofficial project here:

http://www.crouchingtigerhiddenfruitbat.org/Cygwin/timemachine.html

  • Title: drphil.rb - Voting Bot
  • Tags: radiator ruby steem steemdev curation
  • Notes:

New Features

  • Bug/console output fixes.
  • Improved unique_author logic to use account_history instead of internal timer for improved accuracy between runs.
  • Gem update.
  • Additional error handling.
  • Handling bad json_metadata.

Features

  • YAML config.
    • voting_rules
      • winfrey mode that acts like the winfrey bot, all voters vote for everyone
      • drphil mode one random voter votes for everyone (default)
      • following_vote_weight - for accounts that the voter follows
      • followers_vote_weight - for accounts that follow the voter
      • min_rep (default 25.0)
      • min_wait and max_wait (in minutes) so that you can fine-tune voting delay.
      • favorite_accounts list and separate favorites_vote_weight option.
        • Note: votes will be cast for favorites irregardless of rep.
      • enable_comments option to vote for post replies (default false).
      • only_first_posts option to only vote on an author's first post (default false).
      • max_rep option, useful for limiting votes to newer authors (default 99.9).
      • vote_signals account list.
        • Optionally allows multiple bot instances to cooperate by avoiding vote swarms.
        • If enabled, this feature allows cooperation without sharing keys (in drphil mode).
      • min_rep can now accept either a static reputation or a dynamic property.
        • Existing static reputation still supported, e.g.: 25.0
        • Dynamic reputation, e.g.: dynamic:100. This will occasionally query the top 100 trending posts and use the minimum author reputation.
        • Now checking vote_weight: 0.00 % and skipping without broadcast.
          • This is useful for special configurations that only vote for favorites.
        • min_voting_power to create a floor with will allow the voter to recharge over time without having to stop the script.
      • Optionally configure voters as a separate filename. E.g:
        • voters: voters.txt
          • The format for the file is just: account wif (no leading dash, separated by space)
        • Or continue to use the previous format.
      • Also optional support for separate files in each (format one per line or separated by space or both):
        • favorite_accounts
        • skip_accounts
        • skip_tags
        • flag_signals
        • vote_signals
      • only_fully_powered_up which will only vote for posts that receive 100% STEEM Power author rewards.
  • Skip posts with declined payout.
  • Skip posts that already have votes from external scripts and posts that were edited.
  • Argument called replay: allows a replay of n blocks allowing you to catch up to the present.
    • E.g.: ruby drphil.rb replay:90 will replay the last 90 blocks (about 4.5 minutes).
  • Thread management
    • Counter displayed so you know what kind of impact ^C will have.
    • This also keeps the number of threads down when authors edit before Dr. Phil votes.
  • Now streaming on Last Irreversible Block Number, just to be fancy.
  • Now checking for new HF18 cashout_time value (if present).
    • This will skip voting when authors edit their old archived posts.
    • Added unique_author (optional) which takes an integer in minutes. This will limit voting to 1 vote per period. E.g.: Set it to 1440 to only vote for each author once a day.
    • Added max_votes_per_post (optional) which only votes n times per post (winfrey mode only).
    • Added only_tags (optional) which only votes on posts that include these tags.
    • Alternative voting weights all inherit from vote_weight if not present.
    • Favorites (favorite_accounts) can now have individual vote percent.
      • Formatted as: account:weight (e.g.: inertia:100.00)
    • Now checking if any voter can vote at all. If at least one voter has a non-zero vote_weight, return true. Otherwise, don't bother to even queue up a new thread, thus saving memory.
    • Argument called stream:false will exit without streaming the blockchain. Useful in situations where you only want to replay: and exit.

Overview

Dr. Phil (drphil.rb) is reimplementation of the "Winfrey" voting bot specification. The goal is to give everyone an upvote.

One optional improvement is that instead of voting 1% by 100 accounts like the Winfrey bot spec, this script can vote 100% with 1 randomly chosen account.

If the complaint about Winfrey is blockchain bloat, Dr. Phil prescribes weight loss to address this. But this feature would only work if there are enough voters defined in the script. If you plan to use this script for one or two accounts, you'll probably want to adjust the VOTE_WEIGHT constant to something a bit lower.


Install

To use this Radiator bot:

Linux
$ sudo apt-get update
$ sudo apt-get install ruby-full git openssl libssl1.0.0 libssl-dev
$ sudo apt-get upgrade
$ gem install bundler
macOS
$ gem install bundler

I've tested it on various versions of ruby. The oldest one I got it to work was:

ruby 2.0.0p645 (2015-04-13 revision 50299) [x86_64-darwin14.4.0]

First, clone this gist and install the dependencies:

$ git clone https://gist.github.com/61bcc2b821aa5acb24f7fc88921950c7.git drphil
$ cd drphil
$ bundle install

Then run it:

$ ruby drphil.rb

Dr. Phil will now do it's thing. Check here to see an updated version of this bot:

https://gist.github.com/inertia186/61bcc2b821aa5acb24f7fc88921950c7


Upgrade

Typically, you can upgrade to the latest version by this command, from the original directory you cloned into:

$ git pull

Usually, this works fine as long as you haven't modified anything. If you get an error, try this:

$ git stash --all
$ git pull --rebase
$ git stash pop

If you're still having problems, I suggest starting a new clone.


Troubleshooting

Problem: What does this error mean?
drphil.yml:1: syntax error, unexpected ':', expecting end-of-input
Solution: You ran ruby drphil.yml but you should run ruby drphil.rb.

Problem: Everything looks ok, but every time Dr. Phil tries to vote, I get this error:
Unable to vote with <account>.  Invalid version
Solution: You're trying to vote with an invalid key.

Make sure the .yml file voter items have the account name, followed by a space, followed by the account's WIF posting key. Also make sure you have removed the example accounts (social and bad.account are just for testing).

Problem: The node I'm using is down.

Is there a list of nodes?

Solution: Yes, special thanks to @ripplerm.

https://ripplerm.github.io/steem-servers/


See my previous Ruby How To posts in: /f/ruby

Get in touch!

If you're using Dr. Phil, I'd love to hear from you. Drop me a line and tell me what you think! I'm @inertia on STEEM and SteemSpeak.

License

I don't believe in intellectual "property". If you do, consider Dr. Phil as licensed under a Creative Commons License.

voting_rules:
  # mode: drphil - picks one random voter for each post
  # mode: winfrey - cycles all voters on each post
  mode: drphil
  vote_weight: 100.00 %
  favorites_vote_weight: 100.00 %
  following_vote_weight: 100.00 %
  followers_vote_weight: 100.00 %
  enable_comments: false
  only_first_posts: false
  only_fully_powered_up: false
  min_wait: 18
  max_wait: 30
  min_rep: 25.0
  max_rep: 99.9
  min_voting_power: 25.00 %
  # unique_author: 1440
  # max_votes_per_post: 10
  
voters:
  - social 5JrvPrQeBBvCRdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC
  - bad.account 5XXXBadWifXXXdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC

favorite_accounts: inertia banjo
skip_accounts: leeroy.jenkins the.masses danlarimer ned-reddit-login
skip_tags: nsfw test ru--mat bm-open
# only_tags: golos
flag_signals: cheetah kulturagolosa
vote_signals: 

:chain_options:
  :chain: golos
  :url: https://ws.golos.io
# Dr. Phil (drphil) is reimplmentation of the winfrey voting bot.  The goal is
# to give everyone an upvote.  But instead of voting 1% by 100 accounts like
# winfrey, this script will vote 100% with 1 randomly chosen account.
#
# See: https://steemit.com/radiator/@inertia/drphil-rb-voting-bot

require 'rubygems'
require 'bundler/setup'
require 'yaml'

Bundler.require

# If there are problems, this is the most time we'll wait (in seconds).
MAX_BACKOFF = 12.8

VOTE_RECHARGE_PER_DAY = 20.0
VOTE_RECHARGE_PER_HOUR = VOTE_RECHARGE_PER_DAY / 24
VOTE_RECHARGE_PER_MINUTE = VOTE_RECHARGE_PER_HOUR / 60
VOTE_RECHARGE_PER_SEC = VOTE_RECHARGE_PER_MINUTE / 60

@config_path = __FILE__.sub(/\.rb$/, '.yml')

unless File.exist? @config_path
  puts "Unable to find: #{@config_path}"
  exit
end

def parse_voters(voters)
  case voters
  when String
    raise "Not found: #{voters}" unless File.exist? voters

    f = File.open(voters)
    hash = {}
    f.read.each_line do |pair|
      key, value = pair.split(' ')
      hash[key] = value if !!key && !!hash
    end

    hash
  when Array
    a = voters.map{ |v| v.split(' ')}.flatten.each_slice(2)

    return a.to_h if a.respond_to? :to_h

    hash = {}

    voters.each_with_index do |e|
      key, val = e.split(' ')
      hash[key] = val
    end

    hash
  else; raise "Unsupported voters: #{voters}"
  end
end

def parse_list(list)
  if !!list && File.exist?(list)
    f = File.open(list)
    elements = []

    f.each_line do |line|
      elements += line.split(' ')
    end

    elements.uniq.reject(&:empty?).reject(&:nil?)
  else
    list.to_s.split(' ')
  end
end

@config = YAML.load_file(@config_path)
rules = @config['voting_rules']

@voting_rules = {
  mode: rules['mode'] || 'drphil',
  vote_weight: (((rules['vote_weight'] || '100.0 %').to_f) * 100).to_i,
  favorites_vote_weight: (((rules['favorites_vote_weight'] || rules['vote_weight'] || '100.0 %').to_f) * 100).to_i,
  following_vote_weight: (((rules['following_vote_weight'] || rules['vote_weight'] || '100.0 %').to_f) * 100).to_i,
  followers_vote_weight: (((rules['followers_vote_weight'] || rules['vote_weight'] || '100.0 %').to_f) * 100).to_i,
  enable_comments: rules['enable_comments'],
  only_first_posts: rules['only_first_posts'],
  only_fully_powered_up: rules['only_fully_powered_up'],
  min_wait: rules['min_wait'].to_i,
  max_wait: rules['max_wait'].to_i,
  min_rep: (rules['min_rep'] || 25.0),
  max_rep: (rules['max_rep'] || 99.9).to_f,
  min_voting_power: (((rules['min_voting_power'] || '0.0 %').to_f) * 100).to_i,
  unique_author: rules['unique_author'],
  max_votes_per_post: rules['max_votes_per_post'],
}

@voting_rules[:wait_range] = [@voting_rules[:min_wait]..@voting_rules[:max_wait]]

unless @voting_rules[:min_rep] =~ /dynamic:[0-9]+/
  @voting_rules[:min_rep] = @voting_rules[:min_rep].to_f
end

@voting_rules = Struct.new(*@voting_rules.keys).new(*@voting_rules.values)

@voters = parse_voters(@config['voters'])
@favorite_accounts = parse_list(@config['favorite_accounts'])
@skip_accounts = parse_list(@config['skip_accounts'])
@skip_tags = parse_list(@config['skip_tags'])
@only_tags = parse_list(@config['only_tags'])
@skip_apps = parse_list(@config['skip_apps'])
@only_apps = parse_list(@config['only_apps'])
@flag_signals = parse_list(@config['flag_signals'])
@vote_signals = parse_list(@config['vote_signals'])

@favorite_account_weights = @favorite_accounts.map do |account|
  pair = account.split(':')
  next unless pair.size == 2

  pair[1] = (pair[1].to_f * 100).to_i
  pair
end.compact.to_h

@favorite_accounts = @favorite_accounts.map do |account|
  account.split(':').first
end

@chain_options = @config[:chain_options]
@chain_options[:chain] = @chain_options[:chain].to_sym
@chain_options[:logger] = Logger.new(__FILE__.sub(/\.rb$/, '.log'))

def winfrey?; @voting_rules.mode == 'winfrey'; end
def drphil?; @voting_rules.mode == 'drphil'; end
def seinfeld?; @voting_rules.mode == 'seinfeld'; end

if (
    !seinfeld? &&
    @voting_rules.vote_weight == 0 && @voting_rules.favorites_vote_weight == 0 &&
    @voting_rules.following_vote_weight == 0 && @voting_rules.followers_vote_weight == 0
  )
  puts "WARNING: All vote weights are zero.  This is a bot that does nothing."
  @voting_rules.mode = 'seinfeld'
end

@voted_for_authors = {}
@voting_power = {}
@threads = {}
@semaphore = Mutex.new

def to_rep(raw)
  raw = raw.to_i
  neg = raw < 0
  level = Math.log10(raw.abs)
  level = [level - 9, 0].max
  level = (neg ? -1 : 1) * level
  level = (level * 9) + 25

  level
end

def poll_voting_power
  @semaphore.synchronize do
    @api.get_accounts(@voters.keys) do |accounts, error|
      if !!error
        puts "Unable to query voters #{@voters.keys.join(', ')}: #{error}"
        return
      end
      
      accounts.each do |account|
        voting_power = account.voting_power / 100.0
        last_vote_time = Time.parse(account.last_vote_time + 'Z')
        voting_elapse = Time.now.utc - last_vote_time
        current_voting_power = voting_power + (voting_elapse * VOTE_RECHARGE_PER_SEC)
        wasted_voting_power = [current_voting_power - 100.0, 0.0].max
        current_voting_power = ([100.0, current_voting_power].min * 100).to_i
        
        if wasted_voting_power > 0
          puts "\t#{account.name} wasted voting power: #{('%.2f' % wasted_voting_power)} %"
        end
        
        @voting_power[account.name] = current_voting_power
      end
      
      @min_voting_power = @voting_power.values.min
      @max_voting_power = @voting_power.values.max
      @average_voting_power = @voting_power.values.reduce(0, :+) / accounts.size
    end
  end
end

def summary_voting_power
  poll_voting_power
  vp = @average_voting_power / 100.0
  summary = []

  summary << if @voting_power.size > 1
    "Average remaining voting power: #{('%.3f' % vp)} %"
  else
    "Remaining voting power: #{('%.3f' % vp)} %"
  end

  if @voting_power.size > 1 && @max_voting_power > @voting_rules.min_voting_power
    vp = @max_voting_power / 100.0

    summary << "highest account: #{('%.3f' % vp)} %"
  end

  vp = @voting_rules.min_voting_power / 100.0
  summary << "recharging when below: #{('%.3f' % vp)} %"

  summary.join('; ')
end

def voters_recharging
  @voting_power.map do |voter, power|
    voter if power < @voting_rules.min_voting_power
  end.compact
end

def skip_tags_intersection?(json_metadata)
  metadata = JSON[json_metadata || '{}'] rescue {}
  tags = metadata['tags'] || [] rescue []
  tags = [tags].flatten

  (@skip_tags & tags).any?
end

def only_tags_intersection?(json_metadata)
  return true if @only_tags.none? # not set, assume all tags intersect

  metadata = JSON[json_metadata || '{}'] rescue {}
  tags = metadata['tags'] || [] rescue []
  tags = [tags].flatten

  (@only_tags & tags).any?
end

def skip_app?(json_metadata)
  metadata = JSON[json_metadata || '{}'] rescue {}
  app = metadata['app'].to_s.split('/').first rescue 'unknown'

  @skip_apps.include? app
end

def only_app?(json_metadata)
  return true if @only_apps.none?

  metadata = JSON[json_metadata || '{}'] rescue {}
  app = metadata['app'].to_s.split('/').first rescue 'unknown'

  @only_apps.include? app
end

def voted_for_authors
  limit = if @voted_for_authors.empty?
    10000
  else
    300
  end

  @semaphore.synchronize do
    @voters.keys.each do |voter|
      @api.get_account_history(voter, -limit, limit) do |result, error|
        if !!error
          puts "Unable to get account hitory for #{voter}: #{error}"
          next
        end
        
        result.reverse.each do |i, tx|
          op = tx['op']
          next unless op[0] == 'vote'

          timestamp = Time.parse(tx['timestamp'] + 'Z')
          latest = @voted_for_authors[op[1]['author']]

          if latest.nil? || latest < timestamp
            @voted_for_authors[op[1]['author']] = timestamp
          end
        end
      end
    end
  end

  @voted_for_authors
end

def already_voted_for?(author, unique_author = @voting_rules.unique_author)
  return false if unique_author.nil?

  now = Time.now.utc
  voted_in_threshold = []

  voted_for_authors.each do |author, vote_at|
    if now - vote_at < unique_author * 60
      voted_in_threshold << author
    end
  end

  return true if voted_in_threshold.include? author

  false
end

def may_vote?(comment)
  return false if !@voting_rules.enable_comments && !comment.parent_author.empty?
  return false if @skip_tags.include? comment.parent_permlink
  return false if skip_tags_intersection? comment.json_metadata
  return false unless only_tags_intersection? comment.json_metadata
  return false if @skip_accounts.include? comment.author
  return false if skip_app? comment.json_metadata
  return false unless only_app? comment.json_metadata

  # We are checking if any voter can vote at all.  If at least one voter has a
  # non-zero vote_weight, return true.  Otherwise, don't bother to even queue up
  # a thread.
  if @voters.keys.map { |voter| vote_weight(comment.author, voter) > 0.0 }.include? true
    true
  else
    false
  end
end

def min_trending_rep(limit)
  begin
    @semaphore.synchronize do
      if @min_trending_rep.nil? || Random.rand(0..limit) == 13
        puts "Looking up trending up to #{limit} posts."

        @api.get_discussions_by_trending(tag: '', limit: limit) do |trending, error|
          raise error.message if !!error

          @min_trending_rep = trending.map do |c|
            c.author_reputation.to_i
          end.min
  
          puts "Current minimum dynamic rep: #{('%.3f' % to_rep(@min_trending_rep))}"
        end
      end
    end
  rescue => e
    puts "Warning: #{e}"
  end

  @min_trending_rep || 0
end

def skip?(comment, voters)
  if comment.respond_to? :cashout_time # HF18
    if (cashout_time = Time.parse(comment.cashout_time + 'Z')) < Time.now.utc
      puts "Skipped, cashout time has passed (#{cashout_time}):\n\t@#{comment.author}/#{comment.permlink}"
      return true
    end
  end

  if !!@voting_rules.only_first_posts
    begin
      @semaphore.synchronize do
        @api.get_accounts([comment.author]) do |account, error|
          if !!error
            puts "Unable to find first post for #{comment.author}: #{error}"
            return true
          end

          if account.post_count > 1
            puts "Skipped, not first post:\n\t@#{comment.author}/#{comment.permlink}"
            return true
          end
        end
      end
    rescue => e
      puts "Warning: #{e}"
      return true
    end
  end

  if !!@voting_rules.only_fully_powered_up
    unless comment.percent_steem_dollars == 0
      puts "Skipped, reward not fully powered up:\n\t@#{comment.author}/#{comment.permlink}"
      return true
    end
  end

  if comment.max_accepted_payout.split(' ').first == '0.000'
    puts "Skipped, payout declined:\n\t@#{comment.author}/#{comment.permlink}"
    return true
  end

  if voters.empty? && winfrey?
    puts "Skipped, everyone already voted:\n\t@#{comment.author}/#{comment.permlink}"
    return true
  end

  unless @favorite_accounts.include? comment.author
    if @voting_rules.min_rep =~ /dynamic:[0-9]+/
      limit = @voting_rules.min_rep.split(':').last.to_i

      if (rep = comment.author_reputation.to_i) < min_trending_rep(limit)
        # ... rep too low ...
        puts "Skipped, due to low dynamic rep (#{('%.3f' % to_rep(rep))}):\n\t@#{comment.author}/#{comment.permlink}"
        return true
      end
    else
      if (rep = to_rep(comment.author_reputation)) < @voting_rules.min_rep
        # ... rep too low ...
        puts "Skipped, due to low rep (#{('%.3f' % rep)}):\n\t@#{comment.author}/#{comment.permlink}"
        return true
      end
    end

    if (rep = to_rep(comment.author_reputation)) > @voting_rules.max_rep
      # ... rep too high ...
      puts "Skipped, due to high rep (#{('%.3f' % rep)}):\n\t@#{comment.author}/#{comment.permlink}"
      return true
    end
  end

  downvoters = comment.active_votes.map do |v|
    v.voter if v.percent < 0
  end.compact

  if (signal = downvoters & @flag_signals).any?
    # ... Got a signal flag ...
    puts "Skipped, flag signals (#{signals.join(' ')} flagged):\n\t@#{comment.author}/#{comment.permlink}"
    return true
  end

  upvoters = comment.active_votes.map do |v|
    v.voter if v.percent > 0
  end.compact

  if (signals = upvoters & @vote_signals).any?
    # ... Got a signal vote ...
    puts "Skipped, vote signals (#{signals.join(' ')} voted):\n\t@#{comment.author}/#{comment.permlink}"
    return true
  end

  all_voters = comment.active_votes.map(&:voter)

  if (all_voters & voters).any?
    # ... Someone already voted (probably because post was edited) ...
    puts "Skipped, already voted:\n\t@#{comment.author}/#{comment.permlink}"
    return true
  end

  if already_voted_for?(comment.author)
    # ... Already voted in timeframe ...
    puts "Skipped, already voted for @#{comment.author} within #{@voting_rules.unique_author} minutes"
    return true
  end

  false
end

def following?(voter, author)
  @voters_following ||= {}
  following = @voters_following[voter] || []
  count = -1

  if following.empty?
    until count == following.size
      count = following.size
      @follow_api.get_following(voter, following.last, 'blog', 100) do |result, error|
        if !!error
          puts "Unable to get follows for #{voter}: #{error}"
        end
        
        following += result.map(&:following)
        following = following.uniq
      end
    end

    @voters_following[voter] = following
  end

  @voters_following[voter] = nil if Random.rand(0..999) == 13

  following.include? author
end

def follower?(voter, author)
  @voters_followers ||= {}
  followers = @voters_followers[voter] || []
  count = -1

  if followers.empty?
    until count == followers.size
      count = followers.size
      @follow_api.get_followers(voter, followers.last, 'blog', 100) do |result, error|
        if !!error
          puts "Unable to get followers for #{voter}: #{error}"
        end
        
        followers += result.map(&:follower)
        followers = followers.uniq
      end
    end

    @voters_followers[voter] = nil if Random.rand(0..999) == 13

    @voters_followers[voter] = followers
  end

  followers.include? author
end

def vote_weight(author, voter)
  @semaphore.synchronize do
    if @favorite_accounts.include? author
      if @favorite_account_weights.keys.include? author
        @favorite_account_weights[author]
      else
        @voting_rules.favorites_vote_weight
      end
    elsif following? voter, author
      @voting_rules.following_vote_weight
    elsif follower? voter, author
      @voting_rules.followers_vote_weight
    else
      @voting_rules.vote_weight
    end
  end
end

def vote(comment, wait_offset = 0)
  votes_cast = 0
  backoff = 0.2
  slug = "@#{comment.author}/#{comment.permlink}"

  @threads.each do |k, t|
    @threads.delete(k) unless t.alive?
  end

  @semaphore.synchronize do
    if @threads.size != @last_threads_size
      print "Pending votes: #{@threads.size} ... "
      @last_threads_size = @threads.size
    end
  end

  if @threads.keys.include? slug
    puts "Skipped, vote already pending:\n\t#{slug}"
    return
  end

  @threads[slug] = Thread.new do
    comment = @api.get_content(comment.author, comment.permlink) do |comment, error|
      if !!error
        puts error.message
        return
      end
      
      comment
    end

    voters = if winfrey?
      @voters.keys - comment.active_votes.map(&:voter) - voters_recharging
    else
      @voters.keys
    end - voters_recharging

    return if skip?(comment, voters)

    if wait_offset == 0
      timestamp = Time.parse(comment.created + ' Z')
      now = Time.now.utc
      wait_offset = now - timestamp
    end

    if (wait = (Random.rand(*@voting_rules.wait_range) * 60) - wait_offset) > 0
      puts "Waiting #{wait.to_i} seconds to vote for:\n\t#{slug}"
      sleep wait

      @api.get_content(comment.author, comment.permlink) do |comment, error|
        if !!error
          puts "Unable to get comment @#{comment.author}/#{comment.permlink}: #{error}"
        end
        
        return if skip?(comment, voters)
      end
    else
      puts "Catching up to vote for:\n\t#{slug}"
      sleep 3
    end

    loop do
      begin
        break if voters.empty?

        author = comment.author
        permlink = comment.permlink
        voter = voters.sample
        weight = vote_weight(author, voter)

        break if weight == 0.0

        if (vp = @voting_power[voter].to_i) < @voting_rules.min_voting_power
          vp = vp / 100.0

          if @voters.size > 1
            puts "Recharging #{voter} vote power (currently too low: #{('%.3f' % vp)} %)"
          else
            puts "Recharging vote power (currently too low: #{('%.3f' % vp)} %)"
          end
        end

        wif = @voters[voter]
        tx = Radiator::Transaction.new(@chain_options.merge(wif: wif))

        puts "#{voter} voting for #{slug}"

        vote = {
          type: :vote,
          voter: voter,
          author: author,
          permlink: permlink,
          weight: weight
        }

        op = Radiator::Operation.new(vote)
        tx.operations << op
        response = tx.process(true)

        if !!response.error
          message = response.error.message
          if message.to_s =~ /You have already voted in a similar way./
            puts "\tFailed: duplicate vote."
            voters -= [voter]
            next
          elsif message.to_s =~ /Can only vote once every 3 seconds./
            if winfrey? || voters.size == 1
              puts "\tRetrying: voting too quickly."
              sleep 3
            else
              puts "\tSkipped: voting too quickly."
              voters -= [voter]
            end

            next
          elsif message.to_s =~ /Voting weight is too small, please accumulate more voting power or steem power./
            puts "\tFailed: voting weight too small"
            voters -= [voter]
            next
          elsif message.to_s =~ /STEEMIT_UPVOTE_LOCKOUT_HF17/
            puts "\tFailed: upvote lockout (last twelve hours before payout)"
            break
          elsif message.to_s =~ /tapos_block_summary/
            puts "Retrying: tapos_block_summary (?)"
            redo
          elsif message.to_s =~ /now < trx.expiration/
            puts "Retrying: now < trx.expiration (?)"
            redo
          elsif message.to_s =~ /signature is not canonical/
            puts "\tRetrying: signature was not canonical (bug in Radiator?)"
            redo
          end
          raise message
        end

        puts "\tSuccess: #{response.result.to_json}"
        votes_cast += 1

        if winfrey?
          # The winfrey mode keeps voting until there are no more voters of
          # until max_votes_per_post is reached (if set)

          if @voting_rules.max_votes_per_post.nil? || votes_cast < @voting_rules.max_votes_per_post
            voters -= [voter]
            next
          else
            puts "Max votes per post reached."
            break
          end
        end

        # The drphil mode only votes with one key per post.
        break
      rescue => e
        puts "Pausing #{backoff} :: Unable to vote with #{voter}.  #{e}"
        voters -= [voter]
        sleep backoff
        backoff = [backoff * 2, MAX_BACKOFF].min
      end
    end
  end
end

puts "Current mode: #{@voting_rules.mode}.  Accounts voting: #{@voters.size}"
replay = 0
stream = true

ARGV.each do |arg|
  if arg =~ /replay:[0-9]+/
    replay = arg.split('replay:').last.to_i rescue 0
  end
  stream = false if arg == 'stream:false'
end

replay_threads = []

if replay > 0
  replay_threads << Thread.new do
    @api = Radiator::Api.new(@chain_options)
    @follow_api = Radiator::FollowApi.new(@chain_options)
    @stream = Radiator::Stream.new(@chain_options)

    properties = @api.get_dynamic_global_properties.result
    last_irreversible_block_num = properties.last_irreversible_block_num
    block_number = last_irreversible_block_num - replay

    puts "Replaying from block number #{block_number} ..."

    @api.get_blocks(block_number..last_irreversible_block_num) do |block, number|
      next unless !!block

      timestamp = Time.parse(block.timestamp + ' Z')
      now = Time.now.utc
      elapsed = now - timestamp

      block.transactions.each do |tx|
        tx.operations.each do |type, op|
          vote(op, elapsed.to_i) if type == 'comment' && may_vote?(op)
        end
      end
    end

    sleep 3
    puts "Done replaying."
  end
end

unless stream
  replay_threads.map(&:join)
  @threads.values.map(&:join)
  exit
end

puts "Now waiting for new posts."

loop do
  @api = Radiator::Api.new(@chain_options)
  @follow_api = Radiator::FollowApi.new(@chain_options)
  @stream = Radiator::Stream.new(@chain_options)
  op_idx = 0

  begin
    puts summary_voting_power

    @stream.operations(:comment) do |comment|
      next unless may_vote? comment

      if @max_voting_power < @voting_rules.min_voting_power
        vp = @max_voting_power / 100.0

        puts "Recharging vote power (currently too low: #{('%.3f' % vp)} %)"
      end

      vote(comment)
      puts summary_voting_power
    end
  rescue => e
    @api.shutdown
    puts "Unable to stream on current node.  Retrying in 5 seconds.  Error: #{e}"
    sleep 5
  end
end
voting_rules:
  # mode: drphil - picks one random voter for each post
  # mode: winfrey - cycles all voters on each post
  mode: drphil
  vote_weight: 100.00 %
  favorites_vote_weight: 100.00 %
  following_vote_weight: 100.00 %
  followers_vote_weight: 100.00 %
  enable_comments: false
  only_first_posts: false
  only_fully_powered_up: false
  min_wait: 18
  max_wait: 30
  min_rep: 25.0
  max_rep: 99.9
  min_voting_power: 25.00 %
  # unique_author: 1440
  # max_votes_per_post: 10
  
voters:
  - social 5JrvPrQeBBvCRdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC
  - bad.account 5XXXBadWifXXXdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC

favorite_accounts: inertia banjo
skip_accounts: leeroy.jenkins the.masses danlarimer ned-reddit-login
skip_tags: nsfw test
# only_tags: steemit
# only_apps: steemit esteem streemian pysteem steepshot busy chainbb banjo_bot chronicle steemq
# skip_apps: piston
flag_signals: cheetah steemcleaners
vote_signals: 

:chain_options:
  :chain: steem
  :url: https://api.steemit.com