├── Gemfile ├── Procfile ├── README.md ├── botconfig.rb ├── bots.rb ├── content.rb ├── credentials-example.rb ├── maintenance.rb ├── run.rb ├── scheduler.rb └── testcontent.rb /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | ruby '2.3.0' 3 | gem 'twitter_ebooks', '~> 3.1.6' 4 | gem 'platform-api' 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: ruby run.rb 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitter-bot-template 2 | A ruby twitter bot template. 3 | 4 | # Basic instructions 5 | Actual documentation in the form of a tutorial coming soon, I hope. In the meantime this repository should be of interest 6 | to people already knowledgeable about twitter bots. 7 | 8 | Requires twitter_ebooks gem version 3.1.6. 9 | 10 | # Heroku scheduling 11 | Soon apps hosted for free in Heroku will be limited to run 18 hours a day. If a bot 12 | hosted in heroku has no awareness of this, Heroku will put it to sleep at unpredictable 13 | times of the day. This bot template offers a workaround to this issue. You know 14 | have the freedom to schedule at which hours the bot should go to sleep. 15 | 16 | Assuming you know how to setup a bot to run in Heroku, go to the dashboard, find the 17 | bot's app and in the addons section add "Heroku Scheduler" , press save. 18 | 19 | Then click on Heroku Scheduler, a new page will open. Click "Add Job" to add a 20 | schedulable job. Type "ruby scheduler.rb" in the command. Select hourly at 00 minutes. You 21 | may also try with every 10 minutes, but it might be overkill. 22 | 23 | Control of the app's Heroku processes requires a Heroku api token (similar to twitter API tokens). 24 | 25 | # credentials.rb 26 | This file declares the twitter and heroku tokens. See the included example.credentials.rb file for more information. 27 | 28 | # botconfig.rb 29 | This sets a lot of variables, some are important (like who is the owner, what is the bot's name). How often to tweet. Etc. 30 | 31 | # content.rb 32 | The source of behavior for the bot. What tweets to make, how to reply. Support for custom commands... 33 | 34 | # bots.rb 35 | The core. All my twitter bots use the same bots.rb , config and content are the ones that make the difference. Note that this is really my first Ruby project so it could be bette. 36 | 37 | 38 | # maintenance.rb 39 | Because there's a scheduler process constantly turning the bot on and off, we need something to completely disable 40 | execution of a bot in case we need to update it / do tests / no longer want heroku running it / etc. 41 | 42 | A script you can run to enable or disable heroku's execution of your bot. 43 | ruby maintenance.rb off , makes it run (unless the bot is scheduled not to run at the time) 44 | ruby maintenance.rb on , disables execution until we call it with 'off'. 45 | 46 | # scheduler.rb 47 | This is the part that might interest bot makers. Using the heroku API to send a bot to sleep or wake it up. The sleep times are 48 | configured in botconfig.rb 49 | 50 | # Other interesting features 51 | * Tweet throtling , by default the minimum DELAY is 30 seconds, meaning that the bot will make at most 2 tweets per minute regardless of circumstances. 52 | * Depending of how you set up content, the bot can post images and tweet chains with/without images. 53 | * Autofollow back : Instead of automatically following back anyone who follows, it follows back anyone who follows AND seems to interact with the bot. 54 | * Auto unfollow back : If someone unfollows the bot, the bot will eventually find out and unfollow (you can setup exceptions) 55 | * Commands : BOT_OWNER can send commands through DM and make the bot do things, see bots.rb for more info. Specially useful so you don't have to login as the bot to do simple things like following friends or blocking spamming. 56 | * Schedulable tweets: Hard to explain but there's a way to setup schedulable tweets in botconfig.rb and then maybe make content.rb say special things in that case. 57 | This is how [@bmpbug's #on/#off messages](https://twitter.com/search?q=from%3Abmpbug%20%23on%20OR%20%23off&src=typd) work. 58 | -------------------------------------------------------------------------------- /botconfig.rb: -------------------------------------------------------------------------------- 1 | BOT_OWNER = 'your-twitter-user-name' 2 | TWITTER_USERNAME = "test245632" # Ebooks account username 3 | 4 | AUTO_FOLLOW_BACK = false 5 | 6 | # if true, when there is a tweet in the timeline, the bot will check if the user 7 | # follows the bot. If not, the bot will unfollow the account, unless account is 8 | # listed in NEVER_UNFOLLOW_BACK 9 | AUTO_UNFOLLOW_BACK = true 10 | 11 | #list of handles that the bot won't unfollow back: 12 | NEVER_UNFOLLOW_BACK = [ 'twitter' , 'support', 'safety' ] 13 | 14 | # If true then the bot will be able to follow-back accounts that follow the 15 | # bot AND interact with the bot. 16 | REPLY_FOLLOW_BACK = true 17 | 18 | # If true the bot will only reply or RT accounts that are identified as bots. 19 | ONLY_REPLY_RT_BOTS = true 20 | 21 | DELAY = 2..70 # Delay between seeing a tweet and interacting (RT / reply) 22 | FAV_DELAY = 30..100 # Same but for favoriting the tweet 23 | TWEET_CHAIN_DELAY = 10..40 # Delay for tweets in a single chain (if content provides) 24 | 25 | # probability to make a random tweet at a given minute: 26 | TWEET_RATE = 1 / 60.0 27 | # maximum number of minutes between tweets 28 | MAX_TWEET_PERIOD = 60 29 | # minimum number of minutes between tweets (ignoring scheduled and manual ones) 30 | MIN_TWEET_PERIOD = 15 31 | 32 | # Schedule some special messages, to be used by content at given times. 33 | SPECIAL_MESSAGES_SCHEDULE = [ 34 | [5, 58..59, :good_night], #Post a good night message sometime between 5:58 and 5:59 GMT 35 | [12, 0.. 3, :good_morning], #Good morning between 12:00 or 12:03 GMT 36 | # [H, m, :some_name_to_be_sent_to_content ], 37 | # Times are in GMT, 24 horus format 38 | ] 39 | 40 | # A function to decide when to go to sleep (only used 41 | # if the bot is in heroku and the scheduler is setup) 42 | def should_it_be_on() 43 | h = Time.new.gmtime.hour # normally we base it upon the UTC hour value 44 | 45 | if 6 <= h && h < 12 #from 6 UTC to 12 UTC 46 | return false 47 | else 48 | return true 49 | end 50 | end 51 | 52 | # probability to reset the bot at a given minute. 53 | # (resets the people bot has interacted with so they can interact again) 54 | RESET_RATE = 1 / 1000000.0 #rarely it may reset before the day ends 55 | # you can just use 0.0 chance, I am not sure why I made this random vOv 56 | # maximum number of minutes between resets 57 | MAX_RESET_PERIOD = 24 * 60 58 | 59 | # REPLY_MODE can be 60 | #:reply_to_all : All the people (except bots) @-ed in tweet are @-ed in reply 61 | #:reply_to_single : Only @ the author of the tweet. 62 | #:disable_replies : Disable replies altogether. 63 | REPLY_MODE = :reply_to_all 64 | 65 | # these probabilities are ignored (1.0) if :disable_replies 66 | CHANCE_TO_IGNORE_MENTION = 0.05 67 | CHANCE_TO_IGNORE_BOT_MENTION = 0.4 68 | 69 | # Special, Interesting and Cool words are provided by the content class 70 | 71 | # Number of special words needed in tweet to consider it "Special" 72 | SPECIAL_NEEDED = 2 73 | # Chance to favorite a special tweet. 74 | SPECIAL_FAVE_RATE = 0.25 75 | 76 | # Same for all of this: 77 | INTERESTING_NEEDED = 1 78 | INTERESTING_FAVORITE_RATE = 0.1 79 | INTERESTING_RT_RATE = 0 80 | INTERESTING_REPLY_RATE = 1.0 / 60.0 81 | 82 | COOL_NEEDED = 3 83 | COOL_FAVORITE_RATE = 0.5 84 | COOL_RT_RATE = 0.1 85 | COOL_REPLY_RATE = 1.0 / 30.0 86 | 87 | # Twitter's api doesn't consider blocks, so if an annoying person or stalker 88 | # is playing with your bot you need a way to make the bot ignore them completely 89 | # Users in blacklist are ignored by the bot altogether, and also tweets that 90 | # include the user @-ed will be ignored. 91 | 92 | USER_BLACKLIST = [ 93 | 'twitterbothater1', 94 | 'twitterbothater2', 95 | 'twitterbothater3', 96 | ] 97 | 98 | # Bot interacts differently with other bots, but we need to identify them 99 | # somehow. This function determines if twitter handle belongs to a bot. 100 | BOT_LIST = [ 101 | "realgamer9001", 102 | "badideabot", 103 | "gamergatefacts", 104 | "wikisext", 105 | "lexicalorderbot", 106 | "but_if_you_can", 107 | ] 108 | require "set" 109 | BOT_SET = BOT_LIST.map{ |s| s.downcase }.to_set 110 | def is_it_a_bot_name(username) 111 | u = username.downcase 112 | if u.start_with?"@" 113 | u = u[1..u.size] 114 | end 115 | if BOT_SET.include? u 116 | return true 117 | end 118 | return (u.include? "groot") || (u.end_with? "ebooks") 119 | end -------------------------------------------------------------------------------- /bots.rb: -------------------------------------------------------------------------------- 1 | require 'twitter_ebooks' 2 | require 'thread' 3 | 4 | require_relative 'content' 5 | require_relative 'botconfig' 6 | 7 | # a credentials.rb file is needed to declare 4 constants: 8 | #CONSUMER_KEY = app consumer key 9 | #CONSUMER_SECRET = app consumer secret 10 | #OAUTH_TOKEN = ebooks account's oauth token (make sure it has write and DM access) 11 | #OAUTH_TOKEN_SECRET = ebooks account's oauth token secret 12 | require_relative 'credentials' 13 | 14 | # ------------------------------------------------------------------------------ 15 | MAX_TWEET_LENGTH = 140 16 | 17 | TWEET_ERROR_MAX_TRIES = 3 18 | TWEET_ERROR_DELAY = 60 19 | 20 | include Ebooks 21 | 22 | BLACKLIST = USER_BLACKLIST.map{ |s| s.downcase } 23 | AT_BLACKLIST = BLACKLIST.map{ |s| "@" + s } 24 | 25 | IN_NEVER_UNFOLLOW_BACK = NEVER_UNFOLLOW_BACK.map{ |s| s.downcase }.to_set 26 | 27 | class GenBot < Ebooks::Bot 28 | # Configuration here applies to all GenBots 29 | def configure 30 | # Users to block instead of interacting with 31 | self.blacklist = ['tnietzschequote'] 32 | 33 | # Range in seconds to randomize delay when bot.delay is called 34 | self.delay_range = DELAY 35 | end 36 | 37 | def on_startup 38 | @bot = self 39 | @content = nil 40 | @last_tweeted = 0 41 | @last_reset = 0 42 | @in_reply_queue = {} 43 | @follow_back_check = {} 44 | @unfollow_back_check = {} 45 | @have_talked = {} 46 | @tweet_mutex = Mutex.new 47 | @last_scheduled = :none 48 | @ignore_schedule = defined?IGNORE_SCHEDULE 49 | 50 | @content = Content.new(@bot) 51 | @special_tokens, @interesting_tokens, @cool_tokens = @content.get_tokens() 52 | hw = @content.hello_world(MAX_TWEET_LENGTH) 53 | if hw != nil 54 | begin 55 | @bot.twitter.direct_message_create( BOT_OWNER, hw ) 56 | rescue 57 | @bot.log "Unable to send DM to bot owner: #{BOT_OWNER}.\n" 58 | end 59 | end 60 | @last_tweeted = minutes_since_last_tweet(@bot.twitter.user.id) 61 | @bot.log "#{@last_tweeted.to_s} minutes since latest tweet." 62 | 63 | 64 | scheduler.every '1m' do 65 | if !@ignore_schedule && !should_it_be_on() 66 | @bot.log "Bot process caught running off allowed time. Exit." 67 | @bot.log 'If you are just testing the bot use "ruby run.rb --ignore-schedule"' 68 | exit 69 | end 70 | gm = Time.new.gmtime 71 | h = gm.hour 72 | m = gm.min 73 | special = :none 74 | SPECIAL_MESSAGES_SCHEDULE.each do |item| 75 | hc, mc, val = item 76 | if hc == h 77 | if mc.is_a? Range 78 | if mc.include? m 79 | special = val 80 | end 81 | else 82 | if mc == m 83 | special = val 84 | end 85 | end 86 | end 87 | end 88 | disable_special = false 89 | if (@last_scheduled != :none) && (@last_scheduled == special) 90 | disable_special = true 91 | end 92 | @last_scheduled = special 93 | @tweet_mutex.synchronize do 94 | @last_tweeted = @last_tweeted + 1 95 | if ( (special != :none) && ! disable_special) || (@last_tweeted >= MAX_TWEET_PERIOD) || ( (@last_tweeted >= MIN_TWEET_PERIOD) && (rand < TWEET_RATE) ) 96 | if disable_special 97 | do_tweet_chain(:none) 98 | else 99 | do_tweet_chain(special) 100 | end 101 | end 102 | end 103 | @last_reset = @last_reset + 1 104 | if (@last_reset > MAX_RESET_PERIOD) && (rand < RESET_RATE) 105 | @last_reset = 0 106 | @have_talked = {} 107 | @follow_back_check = {} 108 | @unfollow_back_check = {} 109 | end 110 | end 111 | end 112 | 113 | def on_message(dm) 114 | if (dm.sender.screen_name == BOT_OWNER) && (dm.text.start_with?"!") 115 | if dm.text.start_with?"!tweet" 116 | @last_tweeted = MAX_TWEET_PERIOD + 1 117 | end 118 | s = dm.text.split(" ") 119 | if s.size > 1 120 | # These commands exist because there are legitimate use cases for 121 | # them. If you use these commands to break ToS, expect the app 122 | # to become read only or your bot's account or even your account 123 | # to be suspended. 124 | if s[0] == "!reply" 125 | r = s[1].scan(/\d+/).first 126 | reply_command(r) 127 | elsif s[0] == "!follow" || s[0] == "!unfollow" || s[0] == "!block" || s[0] == "!reportspam" 128 | begin 129 | if s[0] == "!follow" 130 | @bot.follow s[1] 131 | # we don't want the bot to unfollow them instantly... 132 | @unfollow_back_check[s[1].downcase] = true 133 | elsif s[0] == "!unfollow" 134 | unfollow s[1] 135 | elsif s[0] == "!block" 136 | # Block command in case undesirable people follow your 137 | # bot or attempt to exploit it. 138 | block s[1] 139 | elsif s[0] == "!reportspam" 140 | # Once your bot becomes popular, specially if it 141 | # follows-back, it will start getting followed by 142 | # spammers, hence why a report spam function is useful 143 | report_spam s[1] 144 | end 145 | rescue 146 | @bot.log "Unable to #{s[0]}: #{s[1]}." 147 | end 148 | end 149 | end 150 | # content class can have commands of its own too 151 | s = @content.command(dm.text) 152 | if s != nil 153 | @bot.reply dm, s 154 | end 155 | else 156 | @bot.delay DELAY do 157 | text = @content.dm_response(dm.sender, dm.text, MAX_TWEET_LENGTH) 158 | if text == nil 159 | @bot.log "Content returned no response for DM." 160 | else 161 | @bot.reply dm, text 162 | end 163 | end 164 | end 165 | 166 | end 167 | 168 | def on_follow(user) 169 | if AUTO_FOLLOW_BACK 170 | @bot.delay DELAY do 171 | @bot.follow user.screen_name 172 | end 173 | end 174 | end 175 | 176 | def on_mention(tweet) 177 | # Reply to a mention 178 | # reply(tweet, meta(tweet).reply_prefix + "oh hullo") 179 | if (REPLY_MODE == :disable_replies) && ! REPLY_FOLLOW_BACK 180 | return 181 | end 182 | uname = tweet.user.screen_name 183 | 184 | 185 | tokens = NLP.tokenize(tweet.text) 186 | return if tokens.find_all { |t| BLACKLIST.include?(t.downcase) || AT_BLACKLIST.include?(t.downcase)}.length > 0 187 | return if BLACKLIST.include?(uname.downcase) 188 | 189 | if REPLY_FOLLOW_BACK 190 | # follow-back maybe 191 | if ! @follow_back_check[uname] then 192 | @follow_back_check[uname] = true 193 | if @bot.twitter.friendship?(tweet.user, TWITTER_USERNAME) && ! @bot.twitter.friendship?(TWITTER_USERNAME, tweet.user) 194 | @bot.delay DELAY do 195 | @bot.log 'Follow-back: ' + uname + "\n" 196 | @bot.twitter.follow tweet.user 197 | end 198 | # force a reply so that when somebody is followed-back this way 199 | # it is easier for owner to notice 200 | if REPLY_MODE != :disable_replies 201 | reply_queue(tweet, meta(tweet)) 202 | end 203 | return 204 | end 205 | end 206 | end 207 | 208 | if REPLY_MODE == :disable_replies 209 | return 210 | end 211 | 212 | # Avoid infinite reply chains even with bots that cannot be 213 | # identified as such 214 | if rand < CHANCE_TO_IGNORE_MENTION 215 | @bot.log 'Ignored mention' 216 | return 217 | end 218 | 219 | # Avoid infinite reply chains (30% chance not to reply other bots) 220 | if is_it_a_bot_name(uname) && (rand < CHANCE_TO_IGNORE_BOT_MENTION) 221 | @bot.log 'Ignored bot mention' 222 | return 223 | end 224 | 225 | reply_queue(tweet, meta(tweet)) 226 | 227 | end 228 | 229 | def on_timeline(tweet) 230 | return if tweet.retweeted_status || tweet.text.start_with?('RT') 231 | uname = tweet.user.screen_name 232 | return if BLACKLIST.include?(uname.downcase) 233 | 234 | if AUTO_UNFOLLOW_BACK 235 | if ! @unfollow_back_check[uname] 236 | @bot.log "Follow back check: @" + uname 237 | @unfollow_back_check[uname] = true 238 | if !@bot.twitter.friendship?(tweet.user, TWITTER_USERNAME) 239 | # doesn't follow back, wtf is the user doing in this timeline? 240 | if ! (IN_NEVER_UNFOLLOW_BACK.include?uname.downcase) 241 | @bot.log "Unfollow" 242 | unfollow uname 243 | return # so the bot doesn't bother interacting 244 | else 245 | @bot.log "User is in NEVER_UNFOLLOW_BACK" 246 | end 247 | else 248 | @bot.log "User follows back." 249 | end 250 | end 251 | end 252 | 253 | s = @content.special_reply(tweet, meta) 254 | if s != nil 255 | @bot.delay DELAY do 256 | @bot.reply tweet, s 257 | end 258 | return 259 | end 260 | tokens = NLP.tokenize(tweet.text) 261 | # We calculate unprompted interaction probability by how well a 262 | # tweet matches our keywords 263 | interesting = tokens.find_all { |t| @interesting_tokens.include?(t.downcase) }.length >= INTERESTING_NEEDED 264 | cool = tokens.find_all { |t| @cool_tokens.include?(t.downcase) }.length > COOL_NEEDED 265 | special = tokens.find_all { |t| @special_tokens.include?(t.downcase) }.length >= SPECIAL_NEEDED 266 | 267 | do_reply = false 268 | do_fave = false 269 | do_rt = false 270 | 271 | if special 272 | do_fave = (rand < SPECIAL_FAVE_RATE ) 273 | end 274 | 275 | if cool || special 276 | do_fave = ( do_fave || (rand < COOL_FAVORITE_RATE) ) 277 | do_rt = (rand < COOL_RT_RATE) 278 | do_reply = (rand < COOL_REPLY_RATE ) 279 | elsif interesting 280 | do_fave = ( do_fave || (rand < INTERESTING_FAVORITE_RATE) ) 281 | do_reply = (rand < INTERESTING_REPLY_RATE) 282 | do_rt = (rand < INTERESTING_RT_RATE) 283 | end 284 | if (tweet.text.count "@") > 0 285 | do_reply = false 286 | do_rt = false 287 | end 288 | 289 | isBot = is_it_a_bot_name(tweet.user.screen_name) 290 | if ONLY_REPLY_RT_BOTS 291 | if (tweet.user.screen_name != BOT_OWNER) && ! isBot 292 | do_reply = false 293 | do_rt = false 294 | end 295 | end 296 | 297 | # Any given user will receive at most one random interaction per day 298 | # (barring special cases) 299 | if !@have_talked[tweet.user.screen_name] 300 | if do_fave 301 | @bot.delay FAV_DELAY do 302 | favorite(tweet) 303 | end 304 | end 305 | if do_rt 306 | @bot.delay FAV_DELAY do 307 | retweet(tweet) 308 | end 309 | end 310 | if do_reply 311 | reply_queue(tweet, meta) 312 | end 313 | if do_rt || do_reply 314 | @have_talked[tweet.user.screen_name] = true 315 | end 316 | end 317 | 318 | end 319 | 320 | def on_favorite(user, tweet) 321 | # Do nothing 322 | end 323 | 324 | def on_retweet(tweet) 325 | # Do nothing 326 | end 327 | 328 | # return 0 on failure 329 | def minutes_since_last_tweet(userid) 330 | x = 0 331 | begin 332 | t = @bot.twitter.user_timeline(count:1).first.created_at 333 | x = [ 1 , ((Time.now - t) / 60).ceil ].max 334 | rescue => e 335 | x = 0 336 | @bot.log "Error fetching latest tweet. Assuming 0 minutes since the latest tweet." 337 | @bot.log(e.message) 338 | end 339 | return x 340 | end 341 | 342 | def tweet_with_media(text, img, sensitive = nil, reply_to = 0) 343 | s1 = "Tweeting" 344 | if reply_to != 0 345 | s1 = "Adding" 346 | end 347 | s2 = "" 348 | if sensitive == :sensitive_media 349 | sensitive = true 350 | s2 = "sensitive " 351 | else 352 | sensitive = false 353 | end 354 | @bot.log "#{s1} [#{text}] with #{s2}image [#{img.path}]" 355 | begin 356 | return @bot.twitter.update_with_media(text, img, possibly_sensitive: sensitive, in_reply_to_status_id: reply_to ) 357 | rescue => e 358 | @bot.log(e.message) 359 | @bot.log(e.backtrace.join("\n")) 360 | return nil 361 | end 362 | end 363 | 364 | def do_tweet_chain(special = :none) 365 | @last_tweeted = 0 366 | if @content.method(:make_tweets).arity == 2 367 | tw = @content.make_tweets( MAX_TWEET_LENGTH, special ) 368 | else 369 | tw = @content.make_tweets(MAX_TWEET_LENGTH) 370 | end 371 | if tw == nil 372 | # if text is nil then content is not ready , wait for another chance 373 | @last_tweeted = MAX_TWEET_PERIOD + 1 374 | @bot.log "tweet: Waiting for content." 375 | else 376 | if tw.is_a? String 377 | tw = [ [tw] ] 378 | end 379 | if tw[0].is_a? String 380 | tw = [tw] 381 | end 382 | 383 | last_tweet = nil 384 | last_tweet_id = 0 385 | for t in tw 386 | if last_tweet_id != 0 387 | sleep rand TWEET_CHAIN_DELAY 388 | end 389 | if t[0] == :retweet 390 | retweet_tweet_id = t[1] 391 | else 392 | text, img, sensitive = t 393 | end 394 | tries = 0 395 | while (tries == 0 || (last_tweet == nil)) && (tries < TWEET_ERROR_MAX_TRIES) 396 | if tries != 0 397 | sleep TWEET_ERROR_DELAY 398 | end 399 | begin 400 | if t[0] == :retweet 401 | the_tweet = twitter.status retweet_tweet_id 402 | @bot.retweet(the_tweet) 403 | last_tweet = the_tweet 404 | elsif img != nil 405 | last_tweet = tweet_with_media(text, img, sensitive, last_tweet_id) 406 | elsif last_tweet_id == 0 407 | last_tweet = @bot.tweet(text) 408 | else 409 | last_tweet = twitter.update(text, {in_reply_to_status_id: last_tweet_id}) 410 | end 411 | rescue => e 412 | @bot.log(e.message) 413 | @bot.log(e.backtrace.join("\n")) 414 | last_tweet = nil 415 | end 416 | tries = tries + 1 417 | if last_tweet == nil 418 | @bot.log("Error detected") 419 | end 420 | end 421 | if last_tweet != nil 422 | last_tweet_id = last_tweet.id 423 | end 424 | end 425 | @last_tweeted = 0 426 | end 427 | 428 | end 429 | 430 | def reply_queue(tweet, meta) 431 | if @in_reply_queue[ tweet.user.screen_name.downcase ] 432 | @bot.log "@" + tweet.user.screen_name + " is already in reply queue, ignoring new mention." 433 | return 434 | end 435 | @in_reply_queue[ tweet.user.screen_name.downcase ] = true 436 | @bot.log "Add @" + tweet.user.screen_name + " to reply queue." 437 | if REPLY_MODE == :reply_to_single 438 | # always @ only the person who @-ed the bot: 439 | if tweet.user.screen_name == TWITTER_USERNAME 440 | rp = '' 441 | else 442 | rp = '@' + tweet.user.screen_name + ' ' 443 | end 444 | else 445 | rp = '' 446 | for s in meta.reply_prefix.split(" ") 447 | if rp == '' 448 | rp = s + " " 449 | elsif ! is_it_a_bot_name(s) 450 | rp = rp + s + " " 451 | end 452 | end 453 | if (tweet.text.count "@") > 4 454 | # too many @-s probably a user trying to exploit bots 455 | rp = '@' + tweet.user.screen_name + ' ' 456 | end 457 | end 458 | Thread.new do 459 | @tweet_mutex.synchronize do 460 | sleep rand DELAY 461 | begin 462 | response = @content.tweet_response(tweet, meta.mentionless, MAX_TWEET_LENGTH - rp.size) 463 | if response.is_a?String 464 | response = [response] 465 | end 466 | rescue Exception => e 467 | @bot.log(e.message) 468 | @bot.log(e.backtrace.join("\n")) 469 | response = nil 470 | end 471 | if response == nil 472 | @in_reply_queue[ tweet.user.screen_name.downcase ] = false 473 | @bot.log "Content returned no response." 474 | @bot.log "Remove @" + tweet.user.screen_name + " from reply queue." 475 | return 476 | end 477 | sensitive = response.delete(:sensitive_media) 478 | dotreply = response.delete(:dot_reply) 479 | single = response.delete(:reply_to_single) 480 | if single == :reply_to_single 481 | # again :( 482 | if tweet.user.screen_name == TWITTER_USERNAME 483 | rp = '' 484 | else 485 | rp = '@' + tweet.user.screen_name + ' ' 486 | end 487 | end 488 | text, img = response 489 | text = rp + text 490 | if dotreply == :dot_reply 491 | text = "." + text 492 | end 493 | error_happened = false 494 | tries = 0 495 | while (tries == 0 || error_happened) && (tries < TWEET_ERROR_MAX_TRIES) 496 | if tries != 0 497 | sleep TWEET_ERROR_DELAY 498 | end 499 | error_happened = false 500 | begin 501 | if img != nil 502 | made_tweet = tweet_with_media(text, img, sensitive, tweet.id ) 503 | else 504 | #made_tweet = @bot.reply tweet, text 505 | made_tweet = twitter.update(text, {in_reply_to_status_id: tweet.id}) 506 | end 507 | if made_tweet == nil 508 | error_happened = true 509 | end 510 | rescue => e 511 | @bot.log(e.message) 512 | @bot.log(e.backtrace.join("\n")) 513 | error_happened = true 514 | end 515 | tries = tries + 1 516 | end 517 | 518 | @bot.log "Remove @" + tweet.user.screen_name + " from reply queue." 519 | @in_reply_queue[ tweet.user.screen_name.downcase ] = false 520 | # one last sleep during the mutex to guarantee the next non-reply tweet 521 | # won't be immediate 522 | sleep rand DELAY 523 | end 524 | end 525 | end 526 | 527 | def favorite(tweet) 528 | @bot.log "Favoriting @#{tweet.user.screen_name}: #{tweet.text}" 529 | @bot.twitter.favorite(tweet.id) 530 | end 531 | 532 | def retweet(tweet) 533 | @bot.log "Retweeting @#{tweet.user.screen_name}: #{tweet.text}" 534 | if tweet.retweeted? 535 | @bot.log "First we need to unretweet it." 536 | # unfortunately the unretweet method was not yet added to twitter gem, 537 | # so, for now we will mess with the api directly :/ 538 | @bot.twitter.send(:perform_post, "/1.1/statuses/unretweet/#{tweet.id}.json") 539 | @bot.log "I hope it was unretweeted." 540 | #@bot.twitter.unretweet(tweet.id) 541 | end 542 | @bot.twitter.retweet(tweet.id) 543 | end 544 | 545 | def unfollow(user) 546 | @bot.log "Unfollowing #{user}" 547 | @bot.twitter.unfollow user 548 | end 549 | 550 | def block(user) 551 | @bot.log "Blocking @#{user}" 552 | @bot.twitter.block(user) 553 | end 554 | 555 | def report_spam(user) 556 | @bot.log "Reporting @#{user}" 557 | @bot.twitter.report_spam(user) 558 | end 559 | 560 | def reply_command(tweetId) 561 | begin 562 | ev = @bot.twitter.status(tweetId) 563 | rescue 564 | @bot.log "Could not retrieve tweet: " + tweetId.to_s 565 | return 566 | end 567 | # now send to the queue. 568 | reply_queue(ev, meta(ev) ) 569 | end 570 | 571 | end 572 | 573 | # Make a MyBot and attach it to an account 574 | GenBot.new(TWITTER_USERNAME) do |bot| 575 | bot.access_token = OAUTH_TOKEN # Token connecting the app to this account 576 | bot.access_token_secret = OAUTH_TOKEN_SECRET # Secret connecting the app to this account 577 | bot.consumer_key = CONSUMER_KEY 578 | bot.consumer_secret = CONSUMER_SECRET 579 | end 580 | -------------------------------------------------------------------------------- /content.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | 3 | def random_number 4 | # twitter gets angry at you if you post the exact same tweet multiple times 5 | # so we cannot just make a bot that says the same thing every time. 6 | # the second-simplest bot example is one that says a random number ... 7 | return rand(10000).to_s 8 | end 9 | 10 | class Content 11 | 12 | def initialize(bot = nil) 13 | ## any initializing code goes here, you might need access to the bot 14 | ## object, fear not, that's what the 'bot' argument is for. Usually 15 | ## content should aim to be independent. Specially so you can use 16 | ## testcontent.rb. Normally. 17 | end 18 | 19 | def get_tokens() 20 | ## 'special' , 'interesting' and 'cool' keywords ## 21 | ## these are keywords that make tweets more likely to get faved, RTed 22 | ## or replied (some restrictions in botconfig.rb apply) 23 | special = ['bot', 'twitter'] 24 | interesting = ['demo', 'magic'] 25 | cool = ['awesome', 'hello', 'world'] 26 | return special, interesting, cool 27 | 28 | ## you can do: 29 | # return [],[],[] 30 | # if you want none of this 31 | end 32 | 33 | def command(text) 34 | ## advanced , if bot owner sends the bot something starting with ! it is 35 | ## sent to this method. If nil is returned, the bot does nothing, else 36 | ## if a string is returned, the bot sends it back. 37 | 38 | # Example: a !test command makes the bot say reply the DM with 39 | # "test complete": 40 | if text.include?"!test" 41 | return "test complete. Have a random number: #{random_number}" 42 | end 43 | end 44 | 45 | def dm_response(user, text, lim) 46 | # How to reply to DMs with a text from user. lim is the limit (usually 140) 47 | # If return is nil , the bot won't reply. 48 | return "Nice DM message. Have a random number: #{random_number}" 49 | end 50 | 51 | def tweet_response(tweet, text, lim) 52 | # How to reply to @-mentions. 53 | # text : Contains the contents of the tweet minus the @-mentions 54 | # lim : Is the character limit for the reply. Don't exceed it. 55 | # Because the bot needs to include other @-mentions in the reply 56 | # this limit is not always 140. 57 | # tweet: Is an object from the sferik twitter library has 58 | # 59 | s = "Nice reply. Have a random number: #{random_number}" 60 | if s.size > lim 61 | # don't exceed lim (it is possible many users are in the chat and 62 | # thus the lim is smaller, don't reply in that case. 63 | return nil 64 | else 65 | return s 66 | end 67 | end 68 | 69 | def hello_world(lim) 70 | # Return a string to send by DM to the bot owner when the bot starts 71 | # execution, useful for debug purposes. But very annoying if always on 72 | # Leave nil so that nothing happens. 73 | return nil 74 | end 75 | 76 | def make_tweets(lim, special) 77 | # This just returns a tweet for the bot to make. 78 | return "This is a tweet. Random number: #{random_number}" 79 | 80 | 81 | # In reality there are many additional things to know: 82 | # 83 | # return some_string, some_file_object 84 | # 85 | # will return a tweet AND attach the file object as media. Typically 86 | # use this for posting images in twitter. 87 | # 88 | # return [ 89 | # [ "hi" ], 90 | # [ "you"], 91 | # ] 92 | # 93 | # This makes a tweet chain, first posts "Hi" then adds a "you" tweet to 94 | # the chain. 95 | # 96 | # There are far more things you should know, like what 'special' is 97 | # about. Hope to have better examples / documentation later. 98 | # 99 | # Return nil if the content is not ready yet, the code will call 100 | # make_tweets at another time. 101 | # 102 | # You can also make it retweet tweets: 103 | # 104 | # return [ 105 | # [ :retweet, tweet_id ], 106 | # ] 107 | # 108 | end 109 | 110 | def special_reply(tweet, meta) 111 | # This allows you to react to tweets in the time line. If the return 112 | # is a string, it will reply with that tweet (you need to include the 113 | # necessary @-s). If the return is nil, do nothing: 114 | 115 | 116 | # in this example whenever someone the bot follows types a tweet 117 | # containing "iddqd", the bot will reply saying "invincible" 118 | if tweet[:text].include? "iddqd" 119 | return meta[:reply_prefix] + ' degreelessness mode on.' 120 | end 121 | # you should really remove this example after testing it. 122 | return nil 123 | end 124 | 125 | 126 | end 127 | 128 | -------------------------------------------------------------------------------- /credentials-example.rb: -------------------------------------------------------------------------------- 1 | # a credentials.rb file is needed to declare 4 constants: 2 | #CONSUMER_KEY = app consumer key 3 | #CONSUMER_SECRET = app consumer secret 4 | #OAUTH_TOKEN = ebooks account's oauth token (make sure it has write and DM access) 5 | #OAUTH_TOKEN_SECRET = ebooks account's oauth token secret 6 | 7 | #HEROKY_API_TOKEN = (only needed if you are using the scheduler) Heroku API key. 8 | 9 | #None of the following is considered a secure way to do this. Usually if you 10 | # care about security you'd replace the string literals below with reading 11 | # environment variables. 12 | # (The security risk is that if anyone finds this file they'll gain total access to 13 | # your bot and even your heroku, you don't want that) 14 | 15 | CONSUMER_KEY = "xxxxxxxxxxxxxxxxxxxxxxxxx" 16 | CONSUMER_SECRET = "yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy" 17 | OAUTH_TOKEN = "0000000000-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz" 18 | OAUTH_TOKEN_SECRET = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" 19 | 20 | HEROKU_APP_NAME = "my-bot-test" 21 | HEROKU_API_TOKEN = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" 22 | HEROKU_PROCESS_TYPE= "worker" # (You'd need to change ProcFile too) 23 | -------------------------------------------------------------------------------- /maintenance.rb: -------------------------------------------------------------------------------- 1 | require_relative 'credentials' 2 | require 'platform-api' 3 | 4 | 5 | if (ARGV.size != 1) || ( (ARGV[0] != 'on') && (ARGV[0] != 'off') ) 6 | puts " ruby maintenance.rb on" 7 | puts "- or -" 8 | puts " ruby maintenance.rb off" 9 | else 10 | heroku = PlatformAPI.connect_oauth(HEROKU_API_TOKEN) 11 | mainten = heroku.app.info(HEROKU_APP_NAME)['maintenance'] 12 | val = (ARGV[0] == 'on') 13 | if (mainten == val) 14 | puts "Maintenance mode is already [#{ARGV[0]}]." 15 | else 16 | puts "Changing maintenance mode to [#{ARGV[0]}]." 17 | end 18 | heroku.app.update(HEROKU_APP_NAME, {'maintenance' => val} ) 19 | load './scheduler.rb' 20 | end 21 | 22 | -------------------------------------------------------------------------------- /run.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | 4 | require_relative 'botconfig' 5 | 6 | ignore_schedule = ARGV.include?"--ignore-schedule" 7 | if ignore_schedule 8 | IGNORE_SCHEDULE = true 9 | puts "Will ignore schedule." 10 | elsif !should_it_be_on() 11 | puts 'Bot process started outside schedule. Halting' 12 | puts 'If you are just testing the bot use "ruby run.rb --ignore-schedule"' 13 | exit 14 | end 15 | 16 | 17 | require_relative 'bots' 18 | 19 | 20 | require 'twitter_ebooks' 21 | # Taken from twitter-ebooks : Temporary measure while migrating to twitter-ebooks 3.0 22 | #require 'ostruct' 23 | #require 'fileutils' 24 | 25 | 26 | bots = Ebooks::Bot.all 27 | 28 | threads = [] 29 | bots.each do |bot| 30 | threads << Thread.new { bot.prepare } 31 | end 32 | threads.each(&:join) 33 | 34 | threads = [] 35 | bots.each do |bot| 36 | threads << Thread.new do 37 | loop do 38 | begin 39 | bot.start 40 | rescue Exception => e 41 | bot.log e.inspect 42 | puts e.backtrace.map { |s| "\t"+s }.join("\n") 43 | end 44 | bot.log "Sleeping before reconnect" 45 | sleep 60 46 | end 47 | end 48 | end 49 | threads.each(&:join) 50 | 51 | 52 | -------------------------------------------------------------------------------- /scheduler.rb: -------------------------------------------------------------------------------- 1 | require 'platform-api' 2 | require_relative 'botconfig' 3 | require_relative 'credentials' 4 | 5 | heroku = PlatformAPI.connect_oauth(HEROKU_API_TOKEN) 6 | 7 | current = heroku.formation.info(HEROKU_APP_NAME, HEROKU_PROCESS_TYPE)['quantity'] 8 | should = should_it_be_on() 9 | 10 | mainten = heroku.app.info(HEROKU_APP_NAME)['maintenance'] 11 | if mainten 12 | should = false 13 | end 14 | 15 | wanted = 0 16 | if should 17 | wanted = 1 18 | end 19 | 20 | if current != wanted 21 | puts "Changing scale from " + current.to_s + " to " + wanted.to_s + "." 22 | heroku.formation.update(HEROKU_APP_NAME, HEROKU_PROCESS_TYPE, {'quantity' => wanted} ) 23 | else 24 | puts "Current scale is already " + current.to_s + ", nothing to change." 25 | end 26 | 27 | if (wanted == 1) && (current == 1) 28 | # Make sure the process is up. It's possible it crashed and Heroku made it 29 | # go to sleep. 30 | bad = true 31 | begin 32 | heroku.dyno.list(HEROKU_APP_NAME).each do |x| 33 | if x["type"] == HEROKU_PROCESS_TYPE 34 | puts "#{x["name"]} is #{x["state"]}." 35 | if (x["state"] == "up") || (x["state"] == "starting") 36 | bad = false 37 | end 38 | end 39 | end 40 | rescue => e 41 | bad = true 42 | puts "Error checking #{HEROKU_PROCESS_TYPE} process, restart" 43 | end 44 | if bad 45 | puts "Restarting dyno." 46 | heroku.dyno.restart(HEROKU_APP_NAME, HEROKU_PROCESS_TYPE) 47 | end 48 | end 49 | 50 | -------------------------------------------------------------------------------- /testcontent.rb: -------------------------------------------------------------------------------- 1 | require 'twitter_ebooks' 2 | require_relative 'content' 3 | 4 | content = Content.new() 5 | 6 | special, inter, cool = content.get_tokens() 7 | 8 | 9 | print inter 10 | print "\n" 11 | print "\n" 12 | print cool 13 | print "\n" 14 | print "\n" 15 | 16 | 17 | for x in 0..200 18 | print "\n" 19 | did = false 20 | tx = content.make_tweets(140, :none) 21 | while tx == nil 22 | print "[not ready]" 23 | sleep(5) 24 | tx = content.make_tweets(140, :none) 25 | end 26 | print "[["+ tx.to_s + "]]" 27 | 28 | print "\n" 29 | print "\n" 30 | end 31 | --------------------------------------------------------------------------------