Conversation-based bot detection and politeness
This commit is contained in:
parent
8708aaa3e3
commit
efde0fd16f
2 changed files with 80 additions and 65 deletions
|
@ -12,62 +12,50 @@ module Ebooks
|
|||
# number of times we've interacted with a timeline tweet, unprompted
|
||||
attr_accessor :pesters_left
|
||||
|
||||
# number of times we've included them in a mention that wasn't from them
|
||||
attr_accessor :includes_left
|
||||
|
||||
def initialize(username)
|
||||
@username = username
|
||||
@pesters_left = 1
|
||||
@includes_left = 2
|
||||
end
|
||||
|
||||
def can_pester?
|
||||
@pesters_left > 0
|
||||
end
|
||||
|
||||
def can_include?
|
||||
@includes_left > 0
|
||||
end
|
||||
end
|
||||
|
||||
# Represents a current "interaction state" with another user
|
||||
class Interaction
|
||||
attr_reader :userinfo, :received, :last_update
|
||||
# Represents a single reply tree of tweets
|
||||
class Conversation
|
||||
attr_reader :last_update
|
||||
|
||||
def initialize(userinfo)
|
||||
@userinfo = userinfo
|
||||
@received = []
|
||||
def initialize(bot)
|
||||
@bot = bot
|
||||
@tweets = []
|
||||
@last_update = Time.now
|
||||
end
|
||||
|
||||
def receive(tweet)
|
||||
@received << tweet
|
||||
def add(tweet)
|
||||
@tweets << tweet
|
||||
@last_update = Time.now
|
||||
|
||||
# When we receive a tweet from someone, become more
|
||||
# inclined to pester them and include in mentions
|
||||
@userinfo.pesters_left += 1
|
||||
@userinfo.includes_left += 2
|
||||
end
|
||||
|
||||
# Make an informed guess as to whether this user is a bot
|
||||
# based on its username and reply speed
|
||||
def is_bot?
|
||||
if @received.length > 2
|
||||
if (@received[-1].created_at - @received[-3].created_at) < 30
|
||||
# Make an informed guess as to whether a user is a bot based
|
||||
# on their behavior in this conversation
|
||||
def is_bot?(username)
|
||||
usertweets = @tweets.select { |t| t.user.screen_name == username }
|
||||
|
||||
if usertweets.length > 2
|
||||
if (usertweets[-1].created_at - usertweets[-3].created_at) < 30
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
@userinfo.username.include?("ebooks")
|
||||
username.include?("ebooks")
|
||||
end
|
||||
|
||||
def continue?
|
||||
if is_bot?
|
||||
true if @received.length < 2
|
||||
else
|
||||
true
|
||||
end
|
||||
# Figure out whether to keep this user in the reply prefix
|
||||
# We want to avoid spamming non-participating users
|
||||
def can_include?(username)
|
||||
@tweets.length <= 4 ||
|
||||
!@tweets[-4..-1].select { |t| t.user.screen_name == username }.empty?
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -99,7 +87,7 @@ module Ebooks
|
|||
# i.e. not self and nobody who has seen too many secondary mentions
|
||||
reply_mentions = @mentions.reject do |m|
|
||||
username = m.downcase
|
||||
username == @bot.username || !@bot.userinfo(username).can_include?
|
||||
username == @bot.username || !@bot.conversation(ev).can_include?(username)
|
||||
end
|
||||
@reply_mentions = ([ev.user.screen_name] + reply_mentions).uniq
|
||||
|
||||
|
@ -130,6 +118,8 @@ module Ebooks
|
|||
# Configuration
|
||||
attr_accessor :username, :delay_range, :blacklist
|
||||
|
||||
attr_accessor :conversations
|
||||
|
||||
@@all = [] # List of all defined bots
|
||||
def self.all; @@all; end
|
||||
|
||||
|
@ -148,7 +138,7 @@ module Ebooks
|
|||
@delay_range ||= 0
|
||||
|
||||
@users ||= {}
|
||||
@interactions ||= {}
|
||||
@conversations ||= {}
|
||||
configure(*args, &b)
|
||||
|
||||
# Tweet ids we've already observed, to avoid duplication
|
||||
|
@ -160,13 +150,29 @@ module Ebooks
|
|||
@users[username] ||= UserInfo.new(username)
|
||||
end
|
||||
|
||||
def interaction(username)
|
||||
if @interactions[username] &&
|
||||
Time.now - @interactions[username].last_update < 600
|
||||
@interactions[username]
|
||||
else
|
||||
@interactions[username] = Interaction.new(userinfo(username))
|
||||
# Grab or create the conversation context for this tweet
|
||||
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
|
||||
|
||||
def twitter
|
||||
|
@ -222,6 +228,7 @@ module Ebooks
|
|||
|
||||
# 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
|
||||
|
@ -229,7 +236,7 @@ module Ebooks
|
|||
|
||||
if meta.mentions_bot?
|
||||
log "Mention from @#{ev.user.screen_name}: #{ev.text}"
|
||||
interaction(ev.user.screen_name).receive(ev)
|
||||
conversation(ev).add(ev)
|
||||
fire(:mention, ev, meta)
|
||||
else
|
||||
fire(:timeline, ev, meta)
|
||||
|
@ -292,13 +299,12 @@ module Ebooks
|
|||
opts = opts.clone
|
||||
|
||||
if ev.is_a? Twitter::DirectMessage
|
||||
return if blacklisted?(ev.sender.screen_name)
|
||||
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 = calc_meta(ev)
|
||||
|
||||
if !interaction(ev.user.screen_name).continue?
|
||||
if conversation(ev).is_bot?(ev.user.screen_name)
|
||||
log "Not replying to suspected bot @#{ev.user.screen_name}"
|
||||
return
|
||||
end
|
||||
|
@ -310,16 +316,9 @@ module Ebooks
|
|||
end
|
||||
end
|
||||
|
||||
meta.reply_mentions.each do |username|
|
||||
# Decrease includes_left for everyone involved here who isn't
|
||||
# directly talking to the bot
|
||||
if !meta.mentions_bot? || username != ev.user.screen_name
|
||||
userinfo(username).includes_left -= 1
|
||||
end
|
||||
end
|
||||
|
||||
log "Replying to @#{ev.user.screen_name} with: #{meta.reply_prefix + text}"
|
||||
twitter.update(meta.reply_prefix + text, in_reply_to_status_id: ev.id)
|
||||
tweet = twitter.update(meta.reply_prefix + text, in_reply_to_status_id: ev.id)
|
||||
conversation(tweet).add(tweet)
|
||||
else
|
||||
raise Exception("Don't know how to reply to a #{ev.class}")
|
||||
end
|
||||
|
@ -329,11 +328,6 @@ module Ebooks
|
|||
return if blacklisted?(tweet.user.screen_name)
|
||||
log "Favoriting @#{tweet.user.screen_name}: #{tweet.text}"
|
||||
|
||||
meta = calc_meta(tweet)
|
||||
#if !meta[:mentions_bot] && !userinfo(ev.user.screen_name).can_pester?
|
||||
# log "Not favoriting: leaving @#{ev.user.screen_name} alone"
|
||||
#end
|
||||
|
||||
begin
|
||||
twitter.favorite(tweet.id)
|
||||
rescue Twitter::Error::Forbidden
|
||||
|
@ -342,7 +336,6 @@ module Ebooks
|
|||
end
|
||||
|
||||
def retweet(tweet)
|
||||
return if blacklisted?(tweet.user.screen_name)
|
||||
log "Retweeting @#{tweet.user.screen_name}: #{tweet.text}"
|
||||
|
||||
begin
|
||||
|
|
|
@ -43,10 +43,11 @@ module Ebooks::Test
|
|||
# Creates a mock tweet
|
||||
# @param username User sending the tweet
|
||||
# @param text Tweet content
|
||||
def mock_tweet(username, text)
|
||||
def mock_tweet(username, text, extra={})
|
||||
mentions = text.split.find_all { |x| x.start_with?('@') }
|
||||
Twitter::Tweet.new(
|
||||
tweet = Twitter::Tweet.new({
|
||||
id: twitter_id,
|
||||
in_reply_to_status_id: 'mock-link',
|
||||
user: { id: twitter_id, screen_name: username },
|
||||
text: text,
|
||||
created_at: Time.now.to_s,
|
||||
|
@ -56,22 +57,29 @@ module Ebooks::Test
|
|||
indices: [text.index(m), text.index(m)+m.length] }
|
||||
}
|
||||
}
|
||||
)
|
||||
}.merge!(extra))
|
||||
tweet
|
||||
end
|
||||
|
||||
def twitter_spy(bot)
|
||||
twitter = spy("twitter")
|
||||
allow(twitter).to receive(:update).and_return(mock_tweet(bot.username, "test tweet"))
|
||||
twitter
|
||||
end
|
||||
|
||||
def simulate(bot, &b)
|
||||
bot.twitter = spy("twitter")
|
||||
bot.twitter = twitter_spy(bot)
|
||||
b.call
|
||||
end
|
||||
|
||||
def expect_direct_message(bot, content)
|
||||
expect(bot.twitter).to have_received(:create_direct_message).with(anything(), content, {})
|
||||
bot.twitter = spy("twitter")
|
||||
bot.twitter = twitter_spy(bot)
|
||||
end
|
||||
|
||||
def expect_tweet(bot, content)
|
||||
expect(bot.twitter).to have_received(:update).with(content, anything())
|
||||
bot.twitter = spy("twitter")
|
||||
bot.twitter = twitter_spy(bot)
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -104,6 +112,20 @@ describe Ebooks::Bot do
|
|||
end
|
||||
end
|
||||
|
||||
it "links tweets to conversations correctly" do
|
||||
tweet1 = mock_tweet("m1sp", "tweet 1", id: 1, in_reply_to_status_id: nil)
|
||||
|
||||
tweet2 = mock_tweet("m1sp", "tweet 2", id: 2, in_reply_to_status_id: 1)
|
||||
|
||||
tweet3 = mock_tweet("m1sp", "tweet 3", id: 3, in_reply_to_status_id: nil)
|
||||
|
||||
bot.conversation(tweet1).add(tweet1)
|
||||
expect(bot.conversation(tweet2)).to eq(bot.conversation(tweet1))
|
||||
|
||||
bot.conversation(tweet2).add(tweet2)
|
||||
expect(bot.conversation(tweet3)).to_not eq(bot.conversation(tweet2))
|
||||
end
|
||||
|
||||
it "stops mentioning people after a certain limit" do
|
||||
simulate(bot) do
|
||||
bot.receive_event(mock_tweet("spammer", "@test_ebooks @m1sp 1"))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue