├── Gemfile ├── Procfile ├── README.md ├── botconfig.rb ├── bots.rb ├── content.rb ├── credentials-example.rb ├── images ├── a.png ├── b.png ├── c.png ├── d.jpg ├── x.png ├── y.png └── z.png ├── maintenance.rb ├── picture_list.rb ├── run.rb ├── scheduler.rb └── testcontent.rb /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | ruby '2.1.5' 3 | gem 'twitter_ebooks', '~> 3.1.6' 4 | gem 'platform-api' 5 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: ruby run.rb 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # twitter-image-bot 2 | 3 | This is a twitter bot that just periodically posts random pictures from a preset list. 4 | 5 | This is basically the source code for [@LegoSpaceBot](https://twitter.com/LegoSpaceBot) but without the set pictures. Enjoy the placeholder letter pictures. 6 | 7 | # twitter-bot-template 8 | 9 | This is based on another project: [The twitter-bot-template](https://github.com/vexorian/twitter-bot-template). Most of the twitter-related work is done by that. In fact, the only difference between this project and the twitter-bot-template is in content.rb and the provided images. 10 | 11 | # Picking a random image 12 | 13 | This project turned out to be notably non-trivial. The simple idea of just picking a random image from the list and posting it had an issue: If you pick N random numbers from a set that is not very large, there will most likely be many repetitions. [The birthday paradox](https://en.wikipedia.org/wiki/Birthday_problem) teaches us that with a set of 365 numbers (which is about 1.5 times larger than the number of available pictures for LegoSpaceBot), even in a sample as small as 23 people, the probability that some will share the picked numbers is 50%. Merely picking a random picture for each tweet would give the impression there are many repeated images. 14 | 15 | The solution couldn't be to just use a database to keep track of which images were recently added - It would require extra resources for what is just a silly bot. Instead, the twitter account's tweet count is used as state. The tweet count is used as the index for the next image. 16 | 17 | Then we also need a way to convert index into a picked image. Once the bot runs out of pictures, it needs to start all over again, but with a different order of pictures. This introduces new issues. The first one is how can you keep multiple orders when all we have to identify the next picture is its index? The answer to this was to divide the index i / N and round it down. With this we can identify which number of random permutation it is. What the bot does is to determine a random seed for the shuffle using i / N as base. 18 | 19 | The second issue is that if we do this and always shuffle the sequence, there are still chances of repetitions. For example, we have two consecutive permutations and because of luck the last image in one is exactly the first image in the second one. This results in the bot having duplicates. To fix this, it actually splits the image list in two random halves and alternates what half to use depending on (i / N) / 2- This way we can guarantee a minimum N/2 distance between two repeated pictures but without sacrificing the sensation of randomness. 20 | 21 | Find the relevant source in content.rb. 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /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 | favorite(tweet) 302 | end 303 | if do_rt 304 | retweet(tweet) 305 | end 306 | if do_reply 307 | reply_queue(tweet, meta) 308 | end 309 | if do_rt || do_reply 310 | @have_talked[tweet.user.screen_name] = true 311 | end 312 | end 313 | 314 | end 315 | 316 | def on_favorite(user, tweet) 317 | # Do nothing 318 | end 319 | 320 | def on_retweet(tweet) 321 | # Do nothing 322 | end 323 | 324 | # return 0 on failure 325 | def minutes_since_last_tweet(userid) 326 | x = 0 327 | begin 328 | t = @bot.twitter.user_timeline(count:1).first.created_at 329 | x = [ 1 , ((Time.now - t) / 60).ceil ].max 330 | rescue => e 331 | x = 0 332 | @bot.log "Error fetching latest tweet. Assuming 0 minutes since the latest tweet." 333 | @bot.log(e.message) 334 | end 335 | return x 336 | end 337 | 338 | def tweet_with_media(text, img, sensitive = nil, reply_to = 0) 339 | s1 = "Tweeting" 340 | if reply_to != 0 341 | s1 = "Adding" 342 | end 343 | s2 = "" 344 | if sensitive == :sensitive_media 345 | sensitive = true 346 | s2 = "sensitive " 347 | else 348 | sensitive = false 349 | end 350 | @bot.log "#{s1} [#{text}] with #{s2}image [#{img.path}]" 351 | begin 352 | return @bot.twitter.update_with_media(text, img, possibly_sensitive: sensitive, in_reply_to_status_id: reply_to ) 353 | rescue => e 354 | @bot.log(e.message) 355 | @bot.log(e.backtrace.join("\n")) 356 | return nil 357 | end 358 | end 359 | 360 | def do_tweet_chain(special = :none) 361 | @last_tweeted = 0 362 | if @content.method(:make_tweets).arity == 2 363 | tw = @content.make_tweets( MAX_TWEET_LENGTH, special ) 364 | else 365 | tw = @content.make_tweets(MAX_TWEET_LENGTH) 366 | end 367 | if tw == nil 368 | # if text is nil then content is not ready , wait for another chance 369 | @last_tweeted = MAX_TWEET_PERIOD + 1 370 | @bot.log "tweet: Waiting for content." 371 | else 372 | if tw.is_a? String 373 | tw = [ [tw] ] 374 | end 375 | if tw[0].is_a? String 376 | tw = [tw] 377 | end 378 | 379 | last_tweet = nil 380 | last_tweet_id = 0 381 | for t in tw 382 | if last_tweet_id != 0 383 | sleep rand TWEET_CHAIN_DELAY 384 | end 385 | text, img, sensitive = t 386 | tries = 0 387 | while (tries == 0 || (last_tweet == nil)) && (tries < TWEET_ERROR_MAX_TRIES) 388 | if tries != 0 389 | sleep TWEET_ERROR_DELAY 390 | end 391 | begin 392 | if img != nil 393 | last_tweet = tweet_with_media(text, img, sensitive, last_tweet_id) 394 | elsif last_tweet_id == 0 395 | last_tweet = @bot.tweet(text) 396 | else 397 | last_tweet = twitter.update(text, {in_reply_to_status_id: last_tweet_id}) 398 | end 399 | rescue => e 400 | @bot.log(e.message) 401 | @bot.log(e.backtrace.join("\n")) 402 | last_tweet = nil 403 | end 404 | tries = tries + 1 405 | if last_tweet == nil 406 | @bot.log("Error detected") 407 | end 408 | end 409 | if last_tweet != nil 410 | last_tweet_id = last_tweet.id 411 | end 412 | end 413 | @last_tweeted = 0 414 | end 415 | 416 | end 417 | 418 | def reply_queue(tweet, meta) 419 | if @in_reply_queue[ tweet.user.screen_name.downcase ] 420 | @bot.log "@" + tweet.user.screen_name + " is already in reply queue, ignoring new mention." 421 | return 422 | end 423 | @in_reply_queue[ tweet.user.screen_name.downcase ] = true 424 | @bot.log "Add @" + tweet.user.screen_name + " to reply queue." 425 | if REPLY_MODE == :reply_to_single 426 | # always @ only the person who @-ed the bot: 427 | if tweet.user.screen_name == TWITTER_USERNAME 428 | rp = '' 429 | else 430 | rp = '@' + tweet.user.screen_name + ' ' 431 | end 432 | else 433 | rp = '' 434 | for s in meta.reply_prefix.split(" ") 435 | if rp == '' 436 | rp = s + " " 437 | elsif ! is_it_a_bot_name(s) 438 | rp = rp + s + " " 439 | end 440 | end 441 | if (tweet.text.count "@") > 4 442 | # too many @-s probably a user trying to exploit bots 443 | rp = '@' + tweet.user.screen_name + ' ' 444 | end 445 | end 446 | Thread.new do 447 | @tweet_mutex.synchronize do 448 | sleep rand DELAY 449 | begin 450 | response = @content.tweet_response(tweet, meta.mentionless, MAX_TWEET_LENGTH - rp.size) 451 | if response.is_a?String 452 | response = [response] 453 | end 454 | rescue Exception => e 455 | @bot.log(e.message) 456 | @bot.log(e.backtrace.join("\n")) 457 | response = nil 458 | end 459 | if response == nil 460 | @in_reply_queue[ tweet.user.screen_name.downcase ] = false 461 | @bot.log "Content returned no response." 462 | @bot.log "Remove @" + tweet.user.screen_name + " from reply queue." 463 | return 464 | end 465 | sensitive = response.delete(:sensitive_media) 466 | dotreply = response.delete(:dot_reply) 467 | single = response.delete(:reply_to_single) 468 | if single == :reply_to_single 469 | # again :( 470 | if tweet.user.screen_name == TWITTER_USERNAME 471 | rp = '' 472 | else 473 | rp = '@' + tweet.user.screen_name + ' ' 474 | end 475 | end 476 | text, img = response 477 | text = rp + text 478 | if dotreply == :dot_reply 479 | text = "." + text 480 | end 481 | error_happened = false 482 | tries = 0 483 | while (tries == 0 || error_happened) && (tries < TWEET_ERROR_MAX_TRIES) 484 | if tries != 0 485 | sleep TWEET_ERROR_DELAY 486 | end 487 | error_happened = false 488 | begin 489 | if img != nil 490 | made_tweet = tweet_with_media(text, img, sensitive, tweet.id ) 491 | else 492 | #made_tweet = @bot.reply tweet, text 493 | made_tweet = twitter.update(text, {in_reply_to_status_id: tweet.id}) 494 | end 495 | if made_tweet == nil 496 | error_happened = true 497 | end 498 | rescue => e 499 | @bot.log(e.message) 500 | @bot.log(e.backtrace.join("\n")) 501 | error_happened = true 502 | end 503 | tries = tries + 1 504 | end 505 | 506 | @bot.log "Remove @" + tweet.user.screen_name + " from reply queue." 507 | @in_reply_queue[ tweet.user.screen_name.downcase ] = false 508 | # one last sleep during the mutex to guarantee the next non-reply tweet 509 | # won't be immediate 510 | sleep rand DELAY 511 | end 512 | end 513 | end 514 | 515 | def favorite(tweet) 516 | @bot.log "Favoriting @#{tweet.user.screen_name}: #{tweet.text}" 517 | @bot.delay FAV_DELAY do 518 | @bot.twitter.favorite(tweet[:id]) 519 | end 520 | end 521 | 522 | def retweet(tweet) 523 | @bot.log "Retweeting @#{tweet.user.screen_name}: #{tweet.text}" 524 | @bot.delay FAV_DELAY do 525 | @bot.twitter.retweet(tweet[:id]) 526 | end 527 | end 528 | 529 | def unfollow(user) 530 | @bot.log "Unfollowing #{user}" 531 | @bot.twitter.unfollow user 532 | end 533 | 534 | def block(user) 535 | @bot.log "Blocking @#{user}" 536 | @bot.twitter.block(user) 537 | end 538 | 539 | def report_spam(user) 540 | @bot.log "Reporting @#{user}" 541 | @bot.twitter.report_spam(user) 542 | end 543 | 544 | def reply_command(tweetId) 545 | begin 546 | ev = @bot.twitter.status(tweetId) 547 | rescue 548 | @bot.log "Could not retrieve tweet: " + tweetId.to_s 549 | return 550 | end 551 | # now send to the queue. 552 | reply_queue(ev, meta(ev) ) 553 | end 554 | 555 | end 556 | 557 | # Make a MyBot and attach it to an account 558 | GenBot.new(TWITTER_USERNAME) do |bot| 559 | bot.access_token = OAUTH_TOKEN # Token connecting the app to this account 560 | bot.access_token_secret = OAUTH_TOKEN_SECRET # Secret connecting the app to this account 561 | bot.consumer_key = CONSUMER_KEY 562 | bot.consumer_secret = CONSUMER_SECRET 563 | end 564 | -------------------------------------------------------------------------------- /content.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | require_relative 'picture_list' 3 | 4 | EXTRA_TEXT = " #Letters" 5 | 6 | # if you've been testing the bot's output, change the initial seed before 7 | # release so that the bot can surprise you. Any integer should do. 8 | INITIAL_SEED = 1777290 9 | 10 | PICTURE_LINK_LENGTH = 23 11 | 12 | # The bot is setup to have a chance to cc some bots that process images. You 13 | # can tweak which accounts to send the images to (If you do this to humans that 14 | # is likely going to classify your bot as spam. You don't want that). 15 | # 16 | # Put AT_PROB = 0.0 to disable this feature. 17 | 18 | AT_PROB = 1.0 / 10.0 19 | 20 | # List of users it might send pictures to: 21 | AT_USERS = [ 22 | "Lowpolybot", 23 | "cgagraphics", 24 | "pixelsorter", 25 | "badpng", 26 | "a_quilt_bot", 27 | "JPGglitchbot", 28 | ] 29 | # A small text to add before the @user: 30 | AT_USER_ADD = ". cc " 31 | 32 | # tweak the PICS constant, with initial seed: 33 | PICS.sort! 34 | PICS.shuffle!( random: Random.new(INITIAL_SEED) ) 35 | class Content 36 | 37 | def get_next_index() 38 | ## Gets the next picture index to post. 39 | 40 | if @bot != nil 41 | image_index = @bot.twitter.user.statuses_count 42 | else 43 | image_index = @test_counter 44 | @test_counter = @test_counter + 1 45 | end 46 | # The sequence is repeated each PICS.size, find the number of repetition 47 | repetition_index = image_index / PICS.size 48 | 49 | # find if the index will belong to the first or second half of the sequence 50 | if image_index % PICS.size < PICS.size / 2 51 | # determine seed deterministically from repetition_index and the half 52 | seed = 2*repetition_index 53 | # we will pick an index from the first half 54 | seq = ( 0.. (PICS.size/2-1) ).to_a 55 | x = image_index % PICS.size 56 | else 57 | # same but now the second half 58 | seed = 2*repetition_index + 1 59 | seq = ( PICS.size/2 .. PICS.size - 1 ).to_a 60 | x = image_index % PICS.size - PICS.size / 2 61 | end 62 | 63 | r = Random.new(seed) 64 | seq.shuffle!( random: r ) #shuffle the indexes using that seed 65 | return seq[x] # pick the index 66 | end 67 | 68 | 69 | def initialize(bot = nil) 70 | ## For initialization we do need to keep track of the bot 71 | ## this way get_next_index can make use of the twitter client 72 | @bot = bot 73 | if @bot == nil 74 | # if @bot is nil this means we are testing the content in 75 | # testcontent.rb, with no access to twitter api to grab the tweet 76 | # count we better simulate one: 77 | @test_counter = 0 78 | end 79 | end 80 | 81 | def get_tokens() 82 | ## 'special' , 'interesting' and 'cool' keywords ## 83 | ## these are keywords that make tweets more likely to get faved, RTed 84 | ## or replied (some restrictions in botconfig.rb apply) 85 | 86 | ## We don't want the bot to do any interactions, so no tokens needed. 87 | return [],[],[] 88 | end 89 | 90 | def command(text) 91 | ## advanced , if bot owner sends the bot something starting with ! it is 92 | ## sent to this method. If nil is returned, the bot does nothing, else 93 | ## if a string is returned, the bot sends it back. 94 | return nil 95 | end 96 | 97 | def dm_response(user, text, lim) 98 | # This bot won't reply to DMs. 99 | return nil 100 | end 101 | 102 | def tweet_response(tweet, text, lim) 103 | # This bot won't reply to tweets. 104 | return nil 105 | end 106 | 107 | def hello_world(lim) 108 | # Return a string to send by DM to the bot owner when the bot starts 109 | # execution, useful for debug purposes. But very annoying if always on 110 | # Leave nil so that nothing happens. 111 | return nil 112 | end 113 | 114 | def make_tweets(lim, special) 115 | # Picks text and an image to tweet. 116 | # - first element of return value is the text. 117 | # - second element is the image. 118 | 119 | # Get the index of the picture to post 120 | i = get_next_index() 121 | 122 | # Should we at someone else in this? 123 | s = PICS[i][1] + EXTRA_TEXT 124 | if s.size > lim 125 | s = s[0..(lim-1)] 126 | else 127 | otherbot = "" 128 | if rand < AT_PROB 129 | otherbot = AT_USERS[ rand( 0..(AT_USERS.size - 1) ) ] 130 | otherbot = "#{AT_USER_ADD}@#{otherbot}" 131 | end 132 | if (s + otherbot).size <= lim 133 | s = s + otherbot 134 | end 135 | end 136 | 137 | # text, image: 138 | return s , File.new(PICS[i][0]) 139 | end 140 | 141 | def special_reply(tweet, meta) 142 | # No special replies 143 | return nil 144 | end 145 | 146 | end -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /images/a.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/a.png -------------------------------------------------------------------------------- /images/b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/b.png -------------------------------------------------------------------------------- /images/c.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/c.png -------------------------------------------------------------------------------- /images/d.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/d.jpg -------------------------------------------------------------------------------- /images/x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/x.png -------------------------------------------------------------------------------- /images/y.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/y.png -------------------------------------------------------------------------------- /images/z.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vexorian/twitter-image-bot/b6523484a117a98c74c5643e7005259b78a4ebe1/images/z.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /picture_list.rb: -------------------------------------------------------------------------------- 1 | # This is just a file to declare the PICS array. Making it separate from content 2 | # allows us to have a large file or maybe a programmatically-generated file. 3 | # Yes, it could have been a text file. 4 | # 5 | # I prefer this method of manually inputing image paths because it allows to 6 | # add captions to them. Some image bots just post all image files they can find 7 | # in a folder. That is also a valid idea. I think you can make something that 8 | # generates this file out of a folder, but with empty captions. I think. 9 | # 10 | # Note that letter d is a jpg it works just fine! Any image format supported 11 | # by twitter should be allowed. Yes, that means GIFs. 12 | # 13 | # In most OSes the file paths are most likely CASE SENSITIVE. 14 | 15 | PICS = [ 16 | ["./images/a.png", "The letter A." ], 17 | ["./images/b.png", "The letter B." ], 18 | ["./images/c.png", "The letter C." ], 19 | ["./images/d.jpg", "The letter D." ], 20 | ["./images/x.png", "The letter X." ], 21 | ["./images/y.png", "The letter Y." ], 22 | ["./images/z.png", "The letter Z." ], 23 | ] 24 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------