Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ausbitbank/574790c07b0c2433794a0720de35e870 to your computer and use it in GitHub Desktop.
Save ausbitbank/574790c07b0c2433794a0720de35e870 to your computer and use it in GitHub Desktop.
Lucky Luke ('luckyluke.rb') for STEEM. Please have a look at the README.md file.
luckyluke.log
.rvmrc
flag_signals.txt │·
skip_accounts.txt │·
skip_tags.txt │·
vote_signals.txt │·
voters.txt
  • Title: luckyluke.rb - Voting Bot
  • Tags: radiator ruby steem steemdev curation
  • Notes:

Lucky Luke is a reimplementation of Dr. Phil, but instead of voting for new articles, it votes for posts mentioned in the memo field of a transfer operation. By default, it votes for any transfer sent to booster but you can configure any bot that receives pay-for-vote transfers. You can also set a minimum transfer amount to ignore small amounts.

Features

  • YAML config.
    • voting_rules
      • min_transfer allows you to specify the minimum amount in the transfer to vote on.
      • min_wait and max_wait (in minutes) so that you can fine-tune voting delay.
      • enable_comments option to vote for post replies (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.
      • 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.
        • 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.
      • only_tags (optional) which only votes on posts that include these tags.
      • 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):
        • skip_accounts
        • skip_tags
        • flag_signals
        • vote_signals
  • bots is a list of bots to watch transfer operations for.
  • 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 luckyluke.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 Lucky Luke votes.
  • Streaming on Last Irreversible Block Number, just to be fancy.
  • Checking for new HF18 cashout_time value (if present).
    • This will skip voting when authors edit their old archived posts.

Overview

The goal is to vote before the pay-for-vote bot. To achieve this, Lucky Luke watches for transfer operations.

You might configure the bot to only watch for transfers over 10.000 SBD, for example. The bot will also use a few other rules like to avoid voting for declined payouts and automatically suspend voting if it needs to recharge.


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

You can try the system version of ruby, but if you have issues with that, use this how-to, and come back to this installation at Step 4:

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]

Setup

First, clone this gist and install the dependencies:

$ git clone https://gist.github.com/07cfb044f625beb22724371b85cea0e4.git luckyluke
$ cd luckyluke
$ bundle install

Then run it:

$ ruby luckyluke.rb

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

https://gist.github.com/inertia186/07cfb044f625beb22724371b85cea0e4


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?
luckyluke.yml:1: syntax error, unexpected ':', expecting end-of-input
Solution: You ran ruby luckyluke.yml but you should run ruby luckyluke.rb.

Problem: Everything looks ok, but every time Lucky Luke 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: #radiator #ruby

Get in touch!

If you're using Lucky Luke, 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 Lucky Luke as licensed under a Creative Commons CC0 License.

source 'https://rubygems.org'
gem 'radiator'
gem 'pry'
GEM
remote: https://rubygems.org/
specs:
bitcoin-ruby (0.0.10)
coderay (1.1.1)
ffi (1.9.18)
hashie (3.5.6)
json (1.8.6)
little-plugger (1.1.4)
logging (2.2.2)
little-plugger (~> 1.1)
multi_json (~> 1.10)
method_source (0.8.2)
multi_json (1.12.1)
net-http-persistent (2.9.4)
pry (0.10.4)
coderay (~> 1.1.0)
method_source (~> 0.8.1)
slop (~> 3.4)
radiator (0.2.2)
bitcoin-ruby (= 0.0.10)
ffi (= 1.9.18)
hashie (~> 3.5, >= 3.5.5)
json (~> 1.8, >= 1.8.6)
logging (~> 2.2, >= 2.2.0)
net-http-persistent (~> 2.9, >= 2.9.4)
slop (3.6.0)
PLATFORMS
ruby
DEPENDENCIES
pry
radiator
BUNDLED WITH
1.14.6
# Lucky Luke (luckyluke) watches transfers sent to pay-for-vote bots and tries
# to vote for the content first (front-running).
#
# See: https://steemit.com/radiator/@inertia/luckyluke-rb-voting-bot
require 'rubygems'
require 'bundler/setup'
require 'yaml'
require 'pry'
Bundler.require
# If there are problems, this is the most time we'll wait (in seconds).
MAX_BACKOFF = 12.8
@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
def parse_slug(slug)
slug = slug.downcase.split('@').last
author_name = slug.split('/')[0]
permlink = slug.split('/')[1..-1].join('/')
permlink = permlink.split('?')[0]
[author_name, permlink]
end
@config = YAML.load_file(@config_path)
rules = @config[:voting_rules]
@voting_rules = {
vote_weight: (((rules[:vote_weight] || '100.0 %').to_f) * 100).to_i,
min_transfer: rules[:min_transfer],
min_transfer_asset: rules[:min_transfer].split(' ').last,
min_transfer_amount: rules[:min_transfer].split(' ').first.to_f,
enable_comments: rules[:enable_comments],
min_wait: rules[:min_wait].to_i,
max_wait: rules[:max_wait].to_i,
min_voting_power: (((rules[:min_voting_power] || '0.0 %').to_f) * 100).to_i,
}
@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])
@bots = parse_list(@config[:bots])
@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])
@options = @config[:chain_options]
@options[:logger] = Logger.new(__FILE__.sub(/\.rb$/, '.log'))
@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
response = @api.get_accounts(@voters.keys)
accounts = response.result
accounts.each do |account|
@voting_power[account.name] = account.voting_power
end
@min_voting_power = accounts.map(&:voting_power).min
@max_voting_power = accounts.map(&:voting_power).max
@average_voting_power = accounts.map(&:voting_power).inject(:+) / accounts.size
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 voters_check_charging
@semaphore.synchronize do
return [] if (Time.now.utc.to_i - @voters_check_charging_at.to_i) < 300
@voters_check_charging_at = Time.now.utc
@voting_power.map do |voter, power|
if power < @voting_rules.min_voting_power
check_time = 4320 # TODO Make this dynamic based on effective voting power
response = @api.get_account_votes(voter)
votes = response.result
latest_vote_at = if votes.any? && !!(time = votes.last.time)
Time.parse(time + 'Z')
end
elapsed = Time.now.utc.to_i - latest_vote_at.to_i
voter if elapsed > check_time
end
end.compact
end
end
def skip_tags_intersection?(json_metadata)
metadata = JSON[json_metadata || '{}']
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 || '{}']
tags = metadata['tags'] || [] rescue []
tags = [tags].flatten
(@only_tags & tags).any?
end
def skip_app?(json_metadata)
metadata = JSON[json_metadata || '{}']
app = metadata['app'].to_s.split('/').first
@skip_apps.include? app
end
def only_app?(json_metadata)
return true if @only_apps.none?
metadata = JSON[json_metadata || '{}']
app = metadata['app'].to_s.split('/').first
@only_apps.include? app
end
def valid_transfer?(transfer)
return false unless @bots.include? transfer.to
return false unless transfer.amount.split(' ').last == @voting_rules.min_transfer_asset
return false unless transfer.amount.split(' ').first.to_f >= @voting_rules.min_transfer_amount
true
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
true
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} transfers."
response = @api.get_discussions_by_trending(tag: '', limit: limit)
raise response.error.message if !!response.error
trending = response.result
@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
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 comment.max_accepted_payout.split(' ').first == '0.000'
puts "Skipped, payout declined:\n\t@#{comment.author}/#{comment.permlink}"
return true
end
if voters.empty?
puts "Skipped, everyone already voted:\n\t@#{comment.author}/#{comment.permlink}"
return true
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
false
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
check_charging = voters_check_charging
voters = @voters.keys - comment.active_votes.map(&:voter) - voters_recharging - voters_recharging + check_charging
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
response = @api.get_content(comment.author, comment.permlink)
comment = response.result
return if skip?(comment, voters)
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 = @voting_rules.vote_weight
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
unless check_charging.include? voter
voters -= [voter]
next
end
end
wif = @voters[voter]
tx = Radiator::Transaction.new(@options.dup.merge(wif: wif))
puts "#{voter} voting for #{slug}"
vote = {
type: :vote,
voter: voter,
author: author,
permlink: permlink,
weight: weight
}
tx.operations << vote
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./
puts "\tRetrying: voting too quickly."
sleep 3
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 =~ /signature is not canonical/
puts "\tRetrying: signature was not canonical (bug in Radiator?)"
redo
end
raise message
else
voters -= [voter]
end
puts "\tSuccess: #{response.result.to_json}"
@voted_for_authors[author] = Time.now.utc
votes_cast += 1
next
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 "Accounts voting: #{@voters.size}"
replay = 0
ARGV.each do |arg|
if arg =~ /replay:[0-9]+/
replay = arg.split('replay:').last.to_i rescue 0
end
end
if replay > 0
Thread.new do
@api = Radiator::Api.new(@options.dup)
@follow_api = Radiator::FollowApi.new(@options.dup)
@stream = Radiator::Stream.new(@options.dup)
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|
if type == 'transfer' && valid_transfer?(op)
author, permlink = parse_slug(op.memo)
comment = @api.get_content(author, permlink).result
if may_vote?(comment)
vote(comment, elapsed.to_i)
end
end
end
end
end
sleep 3
puts "Done replaying."
end
end
puts "Now watching for new transfers to: #{@bots.join(', ')}"
loop do
@api = Radiator::Api.new(@options.dup)
@follow_api = Radiator::FollowApi.new(@options.dup)
@stream = Radiator::Stream.new(@options.dup)
op_idx = 0
begin
puts summary_voting_power
counter = 0
@stream.operations(:transfer) do |transfer|
next unless valid_transfer? transfer
author, permlink = parse_slug(transfer.memo)
comment = @api.get_content(author, permlink).result
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}"
puts e.backtrace
sleep 5
end
end
:voting_rules:
:vote_weight: 100.00 %
:min_transfer: 0.001 SBD
:enable_comments: false
:min_wait: 18
:max_wait: 30
:min_rep: 25.0
:max_rep: 99.9
:min_voting_power: 25.00 %
:voters:
- social 5JrvPrQeBBvCRdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC
- bad.account 5XXXBadWifXXXdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC
:bots: booster
: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://steemd.steemit.com
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment