Class: Ebooks::Bot

Inherits:
Object
  • Object
show all
Defined in:
lib/twitter_ebooks/bot.rb

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(username, &b) ⇒ Bot

Initializes and configures bot

Parameters:

  • args

    Arguments passed to configure method

  • b

    Block to call with new bot



183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/twitter_ebooks/bot.rb', line 183

def initialize(username, &b)
  @blacklist ||= []
  @conversations ||= {}
  # Tweet ids we've already observed, to avoid duplication
  @seen_tweets ||= {}

  @username = username
  @delay_range ||= 1..6
  configure

  b.call(self) unless b.nil?
  Bot.all << self
end

Instance Attribute Details

#access_tokenString

Returns OAuth access token from ‘ebooks auth`.

Returns:

  • (String)

    OAuth access token from ‘ebooks auth`



150
151
152
# File 'lib/twitter_ebooks/bot.rb', line 150

def access_token
  @access_token
end

#access_token_secretString

Returns OAuth access secret from ‘ebooks auth`.

Returns:

  • (String)

    OAuth access secret from ‘ebooks auth`



152
153
154
# File 'lib/twitter_ebooks/bot.rb', line 152

def access_token_secret
  @access_token_secret
end

#blacklistArray<String>

Returns list of usernames to block on contact.

Returns:

  • (Array<String>)

    list of usernames to block on contact



158
159
160
# File 'lib/twitter_ebooks/bot.rb', line 158

def blacklist
  @blacklist
end

#consumer_keyString

Returns OAuth consumer key for a Twitter app.

Returns:

  • (String)

    OAuth consumer key for a Twitter app



146
147
148
# File 'lib/twitter_ebooks/bot.rb', line 146

def consumer_key
  @consumer_key
end

#consumer_secretString

Returns OAuth consumer secret for a Twitter app.

Returns:

  • (String)

    OAuth consumer secret for a Twitter app



148
149
150
# File 'lib/twitter_ebooks/bot.rb', line 148

def consumer_secret
  @consumer_secret
end

#conversationsHash{String => Ebooks::Conversation}

Returns maps tweet ids to their conversation contexts.

Returns:



160
161
162
# File 'lib/twitter_ebooks/bot.rb', line 160

def conversations
  @conversations
end

#delay_rangeRange, Integer

Returns range of seconds to delay in delay method.

Returns:

  • (Range, Integer)

    range of seconds to delay in delay method



162
163
164
# File 'lib/twitter_ebooks/bot.rb', line 162

def delay_range
  @delay_range
end

#userTwitter::User

Returns Twitter user object of bot.

Returns:

  • (Twitter::User)

    Twitter user object of bot



154
155
156
# File 'lib/twitter_ebooks/bot.rb', line 154

def user
  @user
end

#usernameString

Returns Twitter username of bot.

Returns:

  • (String)

    Twitter username of bot



156
157
158
# File 'lib/twitter_ebooks/bot.rb', line 156

def username
  @username
end

Class Method Details

.allArray

Returns list of all defined bots.

Returns:

  • (Array)

    list of all defined bots



165
# File 'lib/twitter_ebooks/bot.rb', line 165

def self.all; @@all ||= []; end

.get(username) ⇒ Ebooks::Bot

Fetches a bot by username

Parameters:

  • username (String)

Returns:



170
171
172
# File 'lib/twitter_ebooks/bot.rb', line 170

def self.get(username)
  all.find { |bot| bot.username.downcase == username.downcase }
end

Instance Method Details

#blacklisted?(username) ⇒ Boolean

Check if a username is blacklisted

Parameters:

  • username (String)

Returns:

  • (Boolean)


447
448
449
450
451
452
453
# File 'lib/twitter_ebooks/bot.rb', line 447

def blacklisted?(username)
  if @blacklist.map(&:downcase).include?(username.downcase)
    true
  else
    false
  end
end

#configureObject

Raises:



197
198
199
# File 'lib/twitter_ebooks/bot.rb', line 197

def configure
  raise ConfigurationError, "Please override the 'configure' method for subclasses of Ebooks::Bot."
end

#conversation(tweet) ⇒ Ebooks::Conversation

Find or create the conversation context for this tweet

Parameters:

  • tweet (Twitter::Tweet)

Returns:



204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'lib/twitter_ebooks/bot.rb', line 204

def conversation(tweet)
  conv = if tweet.in_reply_to_status_id?
    @conversations[tweet.in_reply_to_status_id]
  end

  if conv.nil?
    conv = @conversations[tweet.id] || Conversation.new(self)
  end

  if tweet.in_reply_to_status_id?
    @conversations[tweet.in_reply_to_status_id] = conv
  end
  @conversations[tweet.id] = conv

  # Expire any old conversations to prevent memory growth
  @conversations.each do |k,v|
    if v != conv && Time.now - v.last_update > 3600
      @conversations.delete(k)
    end
  end

  conv
end

#delay(range = @delay_range, &b) ⇒ Object

Delay an action for a variable period of time

Parameters:

  • range (Range, Integer) (defaults to: @delay_range)

    range of seconds to choose for delay



438
439
440
441
442
# File 'lib/twitter_ebooks/bot.rb', line 438

def delay(range=@delay_range, &b)
  time = rand(range) unless range.is_a? Integer
  sleep time
  b.call
end

#favorite(tweet) ⇒ Object

Favorite a tweet

Parameters:

  • tweet (Twitter::Tweet)


485
486
487
488
489
490
491
492
493
# File 'lib/twitter_ebooks/bot.rb', line 485

def favorite(tweet)
  log "Favoriting @#{tweet.user.screen_name}: #{tweet.text}"

  begin
    twitter.favorite(tweet.id)
  rescue Twitter::Error::Forbidden
    log "Already favorited: #{tweet.user.screen_name}: #{tweet.text}"
  end
end

#fire(event, *args) ⇒ Object

Fire an event

Parameters:

  • event (Symbol)

    event to fire

  • args

    arguments for event handler



429
430
431
432
433
434
# File 'lib/twitter_ebooks/bot.rb', line 429

def fire(event, *args)
  handler = "on_#{event}".to_sym
  if respond_to? handler
    self.send(handler, *args)
  end
end

#follow(user, *args) ⇒ Object

Follow a user

Parameters:

  • user (String)

    username or user id



509
510
511
512
# File 'lib/twitter_ebooks/bot.rb', line 509

def follow(user, *args)
  log "Following #{user}"
  twitter.follow(user, *args)
end

#log(*args) ⇒ Object

Logs info to stdout in the context of this bot



175
176
177
178
# File 'lib/twitter_ebooks/bot.rb', line 175

def log(*args)
  STDOUT.print "@#{@username}: " + args.map(&:to_s).join(' ') + "\n"
  STDOUT.flush
end

#meta(ev) ⇒ Ebooks::TweetMeta

Calculate some meta information about a tweet relevant for replying

Parameters:

  • ev (Twitter::Tweet)

Returns:



251
252
253
# File 'lib/twitter_ebooks/bot.rb', line 251

def meta(ev)
  TweetMeta.new(self, ev)
end

#pictweet(txt, pic, *args) ⇒ Object

Tweet some text with an image

Parameters:

  • txt (String)
  • pic (String)

    filename



537
538
539
540
# File 'lib/twitter_ebooks/bot.rb', line 537

def pictweet(txt, pic, *args)
  log "Tweeting #{txt.inspect} - #{pic} #{args}"
  twitter.update_with_media(txt, File.new(pic), *args)
end

#prepareObject

Configures client and fires startup event



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
# File 'lib/twitter_ebooks/bot.rb', line 328

def prepare
  # Sanity check
  if @username.nil?
    raise ConfigurationError, "bot username cannot be nil"
  end

  if @consumer_key.nil? || @consumer_key.empty? ||
     @consumer_secret.nil? || @consumer_key.empty?
    log "Missing consumer_key or consumer_secret. These details can be acquired by registering a Twitter app at https://apps.twitter.com/"
    exit 1
  end

  if @access_token.nil? || @access_token.empty? ||
     @access_token_secret.nil? || @access_token_secret.empty?
    log "Missing access_token or access_token_secret. Please run `ebooks auth`."
    exit 1
  end

  # Save old name
  old_name = username
  # Load user object and actual username
  update_myself
  # Warn about mismatches unless it was clearly intentional
  log "warning: bot expected to be @#{old_name} but connected to @#{username}" unless username == old_name || old_name.empty?

  fire(:startup)
end

#receive_event(ev) ⇒ Object

Receive an event from the twitter stream

Parameters:

  • ev (Object)

    Twitter streaming event



257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
# File 'lib/twitter_ebooks/bot.rb', line 257

def receive_event(ev)
  case ev
  when Array # Initial array sent on first connection
    log "Online!"
    fire(:connect, ev)
    return
  when Twitter::DirectMessage
    return if ev.sender.id == @user.id # Don't reply to self
    log "DM from @#{ev.sender.screen_name}: #{ev.text}"
    fire(:message, ev)
  when Twitter::Tweet
    return unless ev.text # If it's not a text-containing tweet, ignore it
    return if ev.user.id == @user.id # Ignore our own tweets

    if ev.retweet? && ev.retweeted_tweet.user.id == @user.id
      # Someone retweeted our tweet!
      fire(:retweet, ev)
      return
    end

    meta = meta(ev)

    if blacklisted?(ev.user.screen_name)
      log "Blocking blacklisted user @#{ev.user.screen_name}"
      @twitter.block(ev.user.screen_name)
    end

    # Avoid responding to duplicate tweets
    if @seen_tweets[ev.id]
      log "Not firing event for duplicate tweet #{ev.id}"
      return
    else
      @seen_tweets[ev.id] = true
    end

    if meta.mentions_bot?
      log "Mention from @#{ev.user.screen_name}: #{ev.text}"
      conversation(ev).add(ev)
      fire(:mention, ev)
    else
      fire(:timeline, ev)
    end
  when Twitter::Streaming::Event
    case ev.name
    when :follow
      return if ev.source.id == @user.id
      log "Followed by #{ev.source.screen_name}"
      fire(:follow, ev.source)
    when :favorite, :unfavorite
      return if ev.source.id == @user.id # Ignore our own favorites
      log "@#{ev.source.screen_name} #{ev.name.to_s}d: #{ev.target_object.text}"
      fire(ev.name, ev.source, ev.target_object)
    when :user_update
      update_myself ev.source
    end
  when Twitter::Streaming::DeletedTweet
    # Pass
  else
    log ev
  end
end

#reply(ev, text, opts = {}) ⇒ Object

Reply to a tweet or a DM.

Parameters:

  • ev (Twitter::Tweet, Twitter::DirectMessage)
  • text (String)

    contents of reply excluding reply_prefix

  • opts (Hash) (defaults to: {})

    additional params to pass to twitter gem



459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/twitter_ebooks/bot.rb', line 459

def reply(ev, text, opts={})
  opts = opts.clone

  if ev.is_a? Twitter::DirectMessage
    log "Sending DM to @#{ev.sender.screen_name}: #{text}"
    twitter.create_direct_message(ev.sender.screen_name, text, opts)
  elsif ev.is_a? Twitter::Tweet
    meta = meta(ev)

    if conversation(ev).is_bot?(ev.user.screen_name)
      log "Not replying to suspected bot @#{ev.user.screen_name}"
      return false
    end

    text = meta.reply_prefix + text unless text.match(/@#{Regexp.escape ev.user.screen_name}/i)
    log "Replying to @#{ev.user.screen_name} with: #{text}"
    tweet = twitter.update(text, opts.merge(in_reply_to_status_id: ev.id))
    conversation(tweet).add(tweet)
    tweet
  else
    raise Exception("Don't know how to reply to a #{ev.class}")
  end
end

#retweet(tweet) ⇒ Object

Retweet a tweet

Parameters:

  • tweet (Twitter::Tweet)


497
498
499
500
501
502
503
504
505
# File 'lib/twitter_ebooks/bot.rb', line 497

def retweet(tweet)
  log "Retweeting @#{tweet.user.screen_name}: #{tweet.text}"

  begin
    twitter.retweet(tweet.id)
  rescue Twitter::Error::Forbidden
    log "Already retweeted: #{tweet.user.screen_name}: #{tweet.text}"
  end
end

#schedulerRufus::Scheduler

Get a scheduler for this bot

Returns:

  • (Rufus::Scheduler)


530
531
532
# File 'lib/twitter_ebooks/bot.rb', line 530

def scheduler
  @scheduler ||= Rufus::Scheduler.new
end

#startObject

Start polling timelines



357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
# File 'lib/twitter_ebooks/bot.rb', line 357

def start
  log 'starting Twitter timeline polling'

  latest_tweet = 0
  latest_mention = 0
  options_mention = {count: 200}
  options_home = {count: 800}
  persistence_file = "#{@username}.json"
  # Read last polled tweets from a persisted file, if exists
  if File.exist? persistence_file
    options_mention = {count: 200}
    options_home = {count: 800}
    json = JSON.parse(open(persistence_file, 'r').read)
    latest_tweet = json['latest_tweet']
    latest_mention = json['latest_mention']
    options_home[:since_id] = latest_tweet
    options_mention[:since_id] = latest_mention
    log "starting home timeline after tweet ##{latest_tweet}"
    log "starting mentions after tweet ##{latest_mention}"
  else
    # Fresh start - fetch only newest tweet & mention id
    log 'starting without persisted ids - reading newest tweet/mention ...'
    tweets = twitter.home_timeline(options_home)
    tweets.each do |ev|
      latest_tweet = ev.id if ev.id > latest_tweet
      options_home[:since_id] = latest_tweet
    end
    mentions = twitter.mentions_timeline(options_mention)
    mentions.each do |ev|
      latest_mention = ev.id if ev.id > latest_mention
      options_mention[:since_id] = latest_mention
    end
    file = open(persistence_file, 'w')
    file.puts({latest_tweet:  latest_tweet, latest_mention: latest_mention}.to_json)
    file.close
    log '... done'
  end

  log 'starting timeline polling schedulers'      

  # Poll home timeline every 70s (rate limit is 15 GETs/15min)
  scheduler.every '70s' do
    tweets = twitter.home_timeline(options_home)
    log "#{tweets.size} new tweets in timeline"
    tweets.each do |ev|
      latest_tweet = ev.id if ev.id > latest_tweet
      receive_event ev
      options_home[:since_id] = latest_tweet
    end
    file = open(persistence_file, 'w')
    file.puts({latest_tweet:  latest_tweet, latest_mention: latest_mention}.to_json)
    file.close
  end

  # Poll mentions timeline every 20s (rate limit is 75 GETs/15min)
  scheduler.every '20s' do
    mentions = twitter.mentions_timeline(options_mention)
    log "#{mentions.size} new mentions in timeline"
    mentions.each do |ev|
      latest_mention = ev.id if ev.id > latest_mention
      receive_event ev
      options_mention[:since_id] = latest_mention
    end
    file = open(persistence_file, 'w')
    file.puts({latest_tweet:  latest_tweet, latest_mention: latest_mention}.to_json)
    file.close
  end
end

#streamTwitter::Streaming::Client

Returns underlying streaming client from twitter gem.

Returns:

  • (Twitter::Streaming::Client)

    underlying streaming client from twitter gem



239
240
241
242
243
244
245
246
# File 'lib/twitter_ebooks/bot.rb', line 239

def stream
  @stream ||= Twitter::Streaming::Client.new do |config|
    config.consumer_key = @consumer_key
    config.consumer_secret = @consumer_secret
    config.access_token = @access_token
    config.access_token_secret = @access_token_secret
  end
end

#tweet(text, *args) ⇒ Object

Tweet something

Parameters:

  • text (String)


523
524
525
526
# File 'lib/twitter_ebooks/bot.rb', line 523

def tweet(text, *args)
  log "Tweeting '#{text}'"
  twitter.update(text, *args)
end

#twitterTwitter::REST::Client

Returns underlying REST client from twitter gem.

Returns:

  • (Twitter::REST::Client)

    underlying REST client from twitter gem



229
230
231
232
233
234
235
236
# File 'lib/twitter_ebooks/bot.rb', line 229

def twitter
  @twitter ||= Twitter::REST::Client.new do |config|
    config.consumer_key = @consumer_key
    config.consumer_secret = @consumer_secret
    config.access_token = @access_token
    config.access_token_secret = @access_token_secret
  end
end

#unfollow(user, *args) ⇒ Object

Unfollow a user

Parameters:

  • user (String)

    username or user id



516
517
518
519
# File 'lib/twitter_ebooks/bot.rb', line 516

def unfollow(user, *args)
  log "Unfollowing #{user}"
  twitter.unfollow(user, *args)
end

#update_myself(new_me = twitter.user) ⇒ Object

Updates @user and calls on_user_update.



320
321
322
323
324
325
# File 'lib/twitter_ebooks/bot.rb', line 320

def update_myself(new_me=twitter.user)
  @user = new_me if @user.nil? || new_me.id == @user.id
  @username = @user.screen_name
  log 'User information updated'
  fire(:user_update)
end