From cfd7b74d3d708323c2a24f914dc77d235828930a Mon Sep 17 00:00:00 2001 From: Joel McCoy Date: Wed, 10 Dec 2014 02:14:21 -0500 Subject: [PATCH] First commit for Boodoo_Books on _ebooks 3.0.0 - Use .env file for secrets/config values - Maintain follower/following parity periodically TODO: - Support banned terms (override #valid_tweet?) - Commands via DM (at least "tweet" command from owner should work) - Investigate (anti)social behavior more deeply THEN: - Spooling/wait-for-config state for post-deploy, pre-config - Archive/model creation post-deploy - Archive/model periodic refresh --- Gemfile | 3 +- boodoo.rb | 86 ++++++++++++++++++++++++++++++++++++++++++++ bots.rb | 102 ++++++++++++++++++++++++++++++++++++++++------------- sample.env | 18 ++++++++++ 4 files changed, 183 insertions(+), 26 deletions(-) create mode 100644 boodoo.rb create mode 100644 sample.env diff --git a/Gemfile b/Gemfile index f6fb34d..552e9a3 100644 --- a/Gemfile +++ b/Gemfile @@ -1,4 +1,5 @@ source 'http://rubygems.org' ruby '2.1.3' -gem 'twitter_ebooks' +gem 'twitter_ebooks 3.0.0' +gem 'dotenv' diff --git a/boodoo.rb b/boodoo.rb new file mode 100644 index 0000000..7eb93d3 --- /dev/null +++ b/boodoo.rb @@ -0,0 +1,86 @@ +require 'twitter_ebooks' +include Ebooks + +module Ebooks::Boodoo + # supports Ruby Range literal, Fixnum, or Float as string + def parse_num(value) + eval(value.to_s[/^\d+(?:\.{1,3})?\d*$/].to_s) + end + + # Make expected/possible Range + def parse_range(value) + value = parse_num(value) + if value.nil? + value = nil + elsif !value.respond_to?(:to_a) + value = Range.new(value, value) + end + value + end + + def parse_array(value, array_splitter=nil) + array_splitter ||= / *[,;]+ */ + value.split(array_splitter).map(&:strip) + end +end + +class Ebooks::TweetMeta + def is_retweet? + tweet.retweeted_status? || !!tweet.text[/^[RM]T[: ]/i] + end +end + +class Ebooks::Boodoo::BoodooBot < Ebooks::Bot + # A rough error-catch/retry for rate limit, dupe fave, server timeouts + def catch_twitter + begin + yield + rescue Twitter::Error => error + @retries += 1 + raise if @retries > @max_error_retries + if error.class == Twitter::Error::TooManyRequests + reset_in = error.rate_limit.reset_in + log "RATE: Going to sleep for ~#{reset_in / 60} minutes..." + sleep reset_in + retry + elsif error.class == Twitter::Error::Forbidden + # don't count "Already faved/followed" message against attempts + @retries -= 1 if error.to_s.include?("already") + log "WARN: #{error.to_s}" + return true + elsif ["execution", "capacity"].any?(&error.to_s.method(:include?)) + log "ERR: Timeout?\n\t#{error}\nSleeping for #{@timeout_sleep} seconds..." + sleep @timeout_sleep + retry + else + log "Unhandled exception from Twitter: #{error.to_s}" + raise + end + end + end + + # Override Ebooks::Bot#blacklisted? to ensure lower<=>lower check + def blacklisted?(username) + if @blacklist.map(&:downcase).include?(username.downcase) + true + else + false + end + end + + # Follow new followers, unfollow lost followers + def follow_parity + followers = catch_twitter { twitter.followers(:count=>200).map(&:screen_name) } + following = catch_twitter { twitter.following(:count=>200).map(&:screen_name) } + to_follow = followers - following + to_unfollow = following - followers + twitter.follow(to_follow) unless to_follow.empty? + twitter.unfollow(to_unfollow) unless to_unfollow.empty? + @followers = followers + @following = following - to_unfollow + if !(to_follow.empty? || to_unfollow.empty?) + log "Followed #{to_follow.size}; unfollowed #{to_unfollow.size}." + end + end + +end diff --git a/bots.rb b/bots.rb index c4fa9a4..a64d3b5 100644 --- a/bots.rb +++ b/bots.rb @@ -1,4 +1,11 @@ require 'twitter_ebooks' +require_relative 'boodoo' +require 'dotenv' + +include Ebooks::Boodoo + +# Read .env file and set values: +SETTINGS = Dotenv.load # Information about a particular Twitter user we know class UserInfo @@ -10,42 +17,87 @@ class UserInfo # @param username [String] def initialize(username) @username = username - @pesters_left = 1 + @pesters_left = parse_num(SETTINGS['PESTER_COUNT']) || 1 end end -class CloneBot < Ebooks::Bot - attr_accessor :original, :model, :model_path - +class CloneBot < BoodooBot + attr_accessor :original, :model, :model_path, :auth_name + # alias_method :oauth_token, :access_token + # alias_method :oauth_token_secret, :access_token_secret def configure - # Configuration for all CloneBots - self.consumer_key = "" - self.consumer_secret = "" - self.blacklist = ['kylelehk', 'friedrichsays', 'Sudieofna', 'tnietzschequote', 'NerdsOnPeriod', 'FSR', 'BafflingQuotes', 'Obey_Nxme'] + # create attr_accessors for all SETTINGS fields + SETTINGS.keys.map(&:to_s).map(&:downcase).each(&Ebooks::Bot.method(:attr_accessor)) + # String fields taken as-is: + @consumer_key = SETTINGS['CONSUMER_KEY'] + @consumer_secret = SETTINGS['CONSUMER_SECRET'] + @access_token = SETTINGS['ACCESS_TOKEN'] + @access_token_secret =SETTINGS['ACCESS_TOKEN_SECRET'] + @tweet_interval = SETTINGS['TWEET_INTERVAL'] + # @pester_period = SETTINGS['PESTER_PERIOD'] + + # String fields forced to downcase: + @bot_name = SETTINGS['BOT_NAME'].downcase + @original = SETTINGS['SOURCE_USERNAME'].downcase + + # Array fields are CSV or SSV + @blacklist = parse_array(SETTINGS['BLACKLIST']) + @banned_terms = parse_array(SETTINGS['BANNED_TERMS']) + @special_terms = parse_array(SETTINGS['SPECIAL_TERMS']) + + # Fields parsed as Fixnum, Float, or Range: + @default_delay = parse_range(SETTINGS['DEFAULT_DELAY']) + @dm_delay = parse_range(SETTINGS['DM_DELAY']) || parse_range(SETTINGS['DEFAULT_DELAY']) + @mention_delay = parse_range(SETTINGS['MENTION_DELAY']) || parse_range(SETTINGS['DEFAULT_DELAY']) + @timeline_delay = parse_range(SETTINGS['TIMELINE_DELAY']) || parse_range(SETTINGS['DEFAULT_DELAY']) + @tweet_chance = parse_num(SETTINGS['TWEET_CHANCE']) + # @pester_count = parse_num(SETTINGS['PESTER_COUNT']) + @timeout_sleep = parse_num(SETTINGS['TIMEOUT_SLEEP']) + + # from example @userinfo = {} - + + # added for BooDoo variant + @attempts = 0 + @followers = [] + @following = [] + # @have_talked = {} + + # load model file load_model! end - def top100; @top100 ||= model.keywords.take(100); end def top20; @top20 ||= model.keywords.take(20); end - def delay(&b) - sleep (1..4).to_a.sample + def delay(d, &b) + d ||= default_delay + sleep (d || [0]).to_a.sample b.call end def on_startup - scheduler.cron '0 0 * * *' do - # Each day at midnight, post a single tweet - tweet(model.make_statement) + log "I started up!" + scheduler.interval @tweet_interval do + if rand < @tweet_chance + tweet(model.make_statement) + end end + + scheduler.interval @update_follows_interval do + follow_parity + end + + # TODO: This throws a weird error. + # Probably don't need it anyway? + # @auth_name ||= twitter.user.screen_name + # log "Logged in as #{auth_name}" end def on_direct_message(dm) - delay do + # TODO: Add controls here! Especially "tweet" + delay(dm_delay) do reply(dm, model.make_response(dm.text)) end end @@ -54,7 +106,7 @@ class CloneBot < Ebooks::Bot # Become more inclined to pester a user when they talk to us userinfo(tweet.user.screen_name).pesters_left += 1 - delay do + delay(mention_delay) do reply(tweet, model.make_response(meta(tweet).mentionless, meta(tweet).limit)) end end @@ -62,13 +114,13 @@ class CloneBot < Ebooks::Bot def on_timeline(tweet) return if tweet.retweeted_status? return unless can_pester?(tweet.user.screen_name) - + tokens = Ebooks::NLP.tokenize(tweet.text) interesting = tokens.find { |t| top100.include?(t.downcase) } very_interesting = tokens.find_all { |t| top20.include?(t.downcase) }.length > 2 - delay do + delay(timeline_delay) do if very_interesting favorite(tweet) if rand < 0.5 retweet(tweet) if rand < 0.1 @@ -97,7 +149,7 @@ class CloneBot < Ebooks::Bot # Only follow our original user or people who are following our original user # @param user [Twitter::User] def can_follow?(username) - @original.nil? || username == @original || twitter.friendship?(username, @original) + @original.nil? || username == @original || twitter.friendship?(username, @original) || twitter.friendship?(username, @original) || twitter.friendship?(username, auth_name) end def favorite(tweet) @@ -116,7 +168,7 @@ class CloneBot < Ebooks::Bot log "Not following @#{user.screen_name}" end end - + private def load_model! return if @model @@ -128,9 +180,9 @@ class CloneBot < Ebooks::Bot end end -CloneBot.new("abby_ebooks") do |bot| - bot.access_token = "" - bot.access_token_secret = "" - bot.original = "0xabad1dea" + +CloneBot.new(SETTINGS['BOT_NAME']) do |bot| + # CloneBot#configure does everything! + bot end diff --git a/sample.env b/sample.env new file mode 100644 index 0000000..ec775a6 --- /dev/null +++ b/sample.env @@ -0,0 +1,18 @@ +BOT_NAME= +SOURCE_USERNAME= +CONSUMER_KEY= +CONSUMER_SECRET= +ACCESS_TOKEN= +ACCESS_TOKEN_SECRET= +UPDATE_FOLLOWS_INTERVAL=90m +TWEET_INTERVAL=2m +TWEET_CHANCE=0.8 +BLACKLIST=tinysubversions, dril +BANNED_TERMS=voldemort, evgeny morozov, heroku +SPECIAL_TERMS= +DEFAULT_DELAY=5..60 +DM_DELAY=0 +MENTION_DELAY=5..60 +TIMELINE_DELAY=10..600 +TIMEOUT_SLEEP=5 +MAX_ERROR_RETRIES=10