mattmueller-twibot 0.1.7.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,140 @@
1
+ require 'optparse'
2
+
3
+ module Twibot
4
+ #
5
+ # Twibot configuration. Use either Twibot::CliConfig.new or
6
+ # TwibotFileConfig.new setup a new bot from either command line or file
7
+ # (respectively). Configurations can be chained so they override each other:
8
+ #
9
+ # config = Twibot::FileConfig.new
10
+ # config << Twibot::CliConfig.new
11
+ # config.to_hash
12
+ #
13
+ # The preceding example will create a configuration which is based on a
14
+ # configuration file but have certain values overridden from the command line.
15
+ # This can be used for instance to store everything but the Twitter account
16
+ # password in your configuration file. Then you can just provide the password
17
+ # when running the bot.
18
+ #
19
+ class Config
20
+ attr_reader :settings
21
+
22
+ DEFAULT = {
23
+ :host => "twitter.com",
24
+ :min_interval => 30,
25
+ :max_interval => 300,
26
+ :interval_step => 10,
27
+ :log_level => "info",
28
+ :log_file => nil,
29
+ :login => nil,
30
+ :password => nil,
31
+ :process => :new,
32
+ :prompt => false,
33
+ :daemonize => false,
34
+ :include_friends => false,
35
+ :timeline_for => :public
36
+ }
37
+
38
+ def initialize(settings = {})
39
+ @configs = []
40
+ @settings = settings
41
+ end
42
+
43
+ #
44
+ # Add a configuration object to override given settings
45
+ #
46
+ def add(config)
47
+ @configs << config
48
+ self
49
+ end
50
+
51
+ alias_method :<<, :add
52
+
53
+ #
54
+ # Makes it possible to access configuration settings as attributes
55
+ #
56
+ def method_missing(name, *args, &block)
57
+ regex = /=$/
58
+ attr_name = name.to_s.sub(regex, '').to_sym
59
+ return super if name == attr_name && !@settings.key?(attr_name)
60
+
61
+ if name != attr_name
62
+ @settings[attr_name] = args.first
63
+ end
64
+
65
+ @settings[attr_name]
66
+ end
67
+
68
+ #
69
+ # Merges configurations and returns a hash with all options
70
+ #
71
+ def to_hash
72
+ hash = {}.merge(@settings)
73
+ @configs.each { |conf| hash.merge!(conf.to_hash) }
74
+ hash
75
+ end
76
+
77
+ def self.default
78
+ Config.new({}.merge(DEFAULT))
79
+ end
80
+ end
81
+
82
+ #
83
+ # Configuration from command line
84
+ #
85
+ class CliConfig < Config
86
+
87
+ def initialize(args = $*)
88
+ super()
89
+
90
+ @parser = OptionParser.new do |opts|
91
+ opts.banner += "Usage: #{File.basename(Twibot.app_file)} [options]"
92
+
93
+ opts.on("-m", "--min-interval SECS", Integer, "Minimum poll interval in seconds") { |i| @settings[:min_interval] = i }
94
+ opts.on("-x", "--max-interval SECS", Integer, "Maximum poll interval in seconds") { |i| @settings[:max_interval] = i }
95
+ opts.on("-s", "--interval-step SECS", Integer, "Poll interval step in seconds") { |i| @settings[:interval_step] = i }
96
+ opts.on("-f", "--log-file FILE", "Log file") { |f| @settings[:log_file] = f }
97
+ opts.on("-l", "--log-level LEVEL", "Log level (err, warn, info, debug), default id info") { |l| @settings[:log_level] = l }
98
+ opts.on("-u", "--login LOGIN", "Twitter login") { |l| @settings[:login] = l }
99
+ opts.on("-p", "--password PASSWORD", "Twitter password") { |p| @settings[:password] = p }
100
+ opts.on("-h", "--help", "Show this message") { puts opts; exit }
101
+
102
+ begin
103
+ require 'daemons'
104
+ opts.on("-d", "--daemonize", "Run as background process (Not implemented)") { |t| @settings[:daemonize] = true }
105
+ rescue LoadError
106
+ end
107
+
108
+ end.parse!(args)
109
+ end
110
+ end
111
+
112
+ #
113
+ # Configuration from files
114
+ #
115
+ class FileConfig < Config
116
+
117
+ #
118
+ # Accepts a stream or a file to read configuration from
119
+ # Default is to read configuration from ./config/bot.yml
120
+ #
121
+ # If a stream is passed it is not closed from within the method
122
+ #
123
+ def initialize(fos = File.expand_path("config/bot.yml"))
124
+ stream = fos.is_a?(String) ? File.open(fos, "r") : fos
125
+
126
+ begin
127
+ config = YAML.load(stream.read)
128
+ config.symbolize_keys! if config
129
+ rescue Exception => err
130
+ puts err.message
131
+ puts "Unable to load configuration, aborting"
132
+ exit
133
+ ensure
134
+ stream.close if fos.is_a?(String)
135
+ end
136
+
137
+ super config.is_a?(Hash) ? config : {}
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,122 @@
1
+ module Twibot
2
+ module Handlers
3
+ #
4
+ # Add a handler for this bot
5
+ #
6
+ def add_handler(type, handler)
7
+ handlers_for_type(type) << handler
8
+ handler
9
+ end
10
+
11
+ def handlers_for_type(type)
12
+ if type.is_a? Array
13
+ handlers[type.first][type.last] ||= []
14
+ else
15
+ handlers[type] || {}
16
+ end
17
+ end
18
+
19
+ def dispatch(type, message)
20
+ handlers_for_type(type).each { |handler| handler.dispatch(message) }
21
+ end
22
+
23
+ def handlers
24
+ @handlers ||= {
25
+ :message => [],
26
+ :reply => [],
27
+ :tweet => [],
28
+ :follower => [],
29
+ :search => {}
30
+ }
31
+ end
32
+
33
+ def handlers=(hash)
34
+ @handlers = hash
35
+ end
36
+ end
37
+
38
+ #
39
+ # A Handler object is an object which can handle a direct message, tweet or
40
+ # at reply.
41
+ #
42
+ class Handler
43
+ attr_reader :options
44
+ def initialize(pattern = nil, options = {}, &blk)
45
+ if pattern.is_a?(Hash)
46
+ options = pattern
47
+ pattern = nil
48
+ end
49
+
50
+ @options = options
51
+ @options[:from].collect! { |s| s.to_s } if @options[:from] && @options[:from].is_a?(Array)
52
+ @options[:from] = [@options[:from].to_s] if @options[:from] && @options[:from].is_a?(String)
53
+ @handler = nil
54
+ @handler = block_given? ? blk : nil
55
+ self.pattern = pattern
56
+ end
57
+
58
+ #
59
+ # Parse pattern string and set options
60
+ #
61
+ def pattern=(pattern)
62
+ return if pattern.nil? || pattern == ""
63
+
64
+ if pattern.is_a?(Regexp)
65
+ @options[:pattern] = pattern
66
+ return
67
+ end
68
+
69
+ words = pattern.split.collect { |s| s.strip } # Get all words in pattern
70
+ @options[:tokens] = words.inject([]) do |sum, token| # Find all tokens, ie :symbol :like :names
71
+ next sum unless token =~ /^:.*/ # Don't process regular words
72
+ sym = token.sub(":", "").to_sym # Turn token string into symbol, ie ":token" => :token
73
+ regex = @options[sym] || '[^\s]+' # Fetch regex if configured, else use any character but space matching
74
+ pattern.sub!(/(^|\s)#{token}(\s|$)/, '\1(' + regex.to_s + ')\2') # Make sure regex captures named switch
75
+ sum << sym
76
+ end
77
+
78
+ @options[:pattern] = /#{pattern}(\s.+)?/
79
+ end
80
+
81
+ #
82
+ # Determines if this handler is suited to handle an incoming message
83
+ #
84
+ def recognize?(message)
85
+ return false if @options[:pattern] && message.text !~ @options[:pattern] # Pattern check
86
+
87
+ users = @options[:from] ? @options[:from] : nil
88
+ sender = message.respond_to?(:sender) ? message.sender : message.user
89
+ return false if users && !users.include?(sender.screen_name.downcase) # Check allowed senders
90
+ true
91
+ end
92
+
93
+ #
94
+ # Process message to build params hash and pass message along with params of
95
+ # to +handle+
96
+ #
97
+ def dispatch(message)
98
+ return unless recognize?(message)
99
+ @params = {}
100
+
101
+ if @options[:pattern] && @options[:tokens]
102
+ matches = message.text.match(@options[:pattern])
103
+ @options[:tokens].each_with_index { |token, i| @params[token] = matches[i+1] }
104
+ @params[:text] = (matches[@options[:tokens].length+1] || "").strip
105
+ elsif @options[:pattern] && !@options[:tokens]
106
+ @params = message.text.match(@options[:pattern]).to_a[1..-1] || []
107
+ else
108
+ @params[:text] = message.text
109
+ end
110
+
111
+ handle(message, @params)
112
+ end
113
+
114
+ #
115
+ # Handle a message. Calls the internal Proc with the message and the params
116
+ # hash as parameters.
117
+ #
118
+ def handle(message, params)
119
+ @handler.call(message, params) if @handler
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,101 @@
1
+ module Twibot
2
+ @@prompt = false
3
+
4
+ def self.prompt=(p)
5
+ @@prompt = f
6
+ end
7
+
8
+ module Macros
9
+ def self.included(mod)
10
+ @@bot = nil
11
+ end
12
+
13
+ def configure(&blk)
14
+ bot.configure(&blk)
15
+ end
16
+
17
+ def message(pattern = nil, options = {}, &blk)
18
+ add_handler(:message, pattern, options, &blk)
19
+ end
20
+
21
+ def reply(pattern = nil, options = {}, &blk)
22
+ add_handler(:reply, pattern, options, &blk)
23
+ end
24
+
25
+ def tweet(pattern = nil, options = {}, &blk)
26
+ add_handler(:tweet, pattern, options, &blk)
27
+ end
28
+
29
+ def follower(&blk)
30
+ add_handler(:follower, nil, {}, &blk)
31
+ end
32
+
33
+ def hashtag(tag_or_tags, pattern = nil, options = {}, &blk)
34
+ query = [tag_or_tags].flatten.map {|ht| ht.to_s[0] == ?# ? ht.to_s : "##{ht}"}.join(" OR ")
35
+ add_handler([:search, query], pattern, options, &blk)
36
+ end
37
+ alias_method :hashtags, :hashtag
38
+
39
+ def search(query, pattern = nil, options = {}, &blk)
40
+ add_handler([:search, query], pattern, options, &blk)
41
+ end
42
+
43
+ def after(event=:all, &blk)
44
+ add_hook :"after_#{event}", &blk
45
+ end
46
+
47
+ def before(event=:all, &blk)
48
+ add_hook :"before_#{event}", &blk
49
+ end
50
+
51
+ def twitter
52
+ bot.twitter
53
+ end
54
+
55
+ alias_method :client, :twitter
56
+
57
+ def post_tweet(msg)
58
+ message = msg.respond_to?(:text) ? msg.text : msg
59
+ puts message
60
+ client.status(:post, message)
61
+ end
62
+
63
+ def post_reply(status, msg)
64
+ text = msg.respond_to?(:text) ? msg.text : msg
65
+ reply_to_screen_name = status.user.screen_name
66
+ reply_to_status_id = status.id
67
+ message = "@#{reply_to_screen_name} #{text}"
68
+ puts message
69
+ client.status(:reply, message, reply_to_status_id)
70
+ end
71
+
72
+ def run?
73
+ !@@bot.nil?
74
+ end
75
+
76
+ private
77
+ def add_handler(type, pattern, options, &blk)
78
+ bot.add_handler(type, Twibot::Handler.new(pattern, options, &blk))
79
+ end
80
+
81
+ def add_hook(hook, &blk)
82
+ bot.add_hook(hook, &blk)
83
+ end
84
+
85
+ def bot
86
+ return @@bot unless @@bot.nil?
87
+
88
+ begin
89
+ @@bot = Twibot::Bot.new nil, true
90
+ rescue Exception
91
+ @@bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new, true)
92
+ end
93
+
94
+ @@bot
95
+ end
96
+
97
+ def self.bot=(bot)
98
+ @@bot = bot
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,4 @@
1
+ module Twibot
2
+ module Tweets
3
+ end
4
+ end
@@ -0,0 +1,300 @@
1
+ require File.expand_path(File.join(File.dirname(__FILE__), 'test_helper')) unless defined?(Twibot)
2
+ require 'fileutils'
3
+
4
+ class TestBot < Test::Unit::TestCase
5
+ should "not raise errors when initialized" do
6
+ assert_nothing_raised do
7
+ Twibot::Bot.new Twibot::Config.new
8
+ end
9
+ end
10
+
11
+ should "raise errors when initialized without config file" do
12
+ assert_raise SystemExit do
13
+ Twibot::Bot.new
14
+ end
15
+ end
16
+
17
+ should "not raise error on initialize when config file exists" do
18
+ if File.exists?("config")
19
+ FileUtils.rm("config/bot.yml")
20
+ else
21
+ FileUtils.mkdir("config")
22
+ end
23
+
24
+ File.open("config/bot.yml", "w") { |f| f.puts "" }
25
+
26
+ assert_nothing_raised do
27
+ Twibot::Bot.new
28
+ end
29
+
30
+ FileUtils.rm_rf("config")
31
+ end
32
+
33
+ should "provide configuration settings as methods" do
34
+ bot = Twibot::Bot.new Twibot::Config.new(:max_interval => 3)
35
+ assert_equal 3, bot.max_interval
36
+ end
37
+
38
+ should "return logger instance" do
39
+ bot = Twibot::Bot.new(Twibot::Config.default << Twibot::Config.new)
40
+ assert bot.log.is_a?(Logger)
41
+ end
42
+
43
+ should "respect configured log level" do
44
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "info"))
45
+ assert_equal Logger::INFO, bot.log.level
46
+
47
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "warn"))
48
+ assert_equal Logger::WARN, bot.log.level
49
+ end
50
+
51
+ should "should return false from receive without handlers" do
52
+ bot = Twibot::Bot.new(Twibot::Config.new)
53
+ assert !bot.receive_messages
54
+ assert !bot.receive_replies
55
+ assert !bot.receive_tweets
56
+ end
57
+
58
+ context "with the process option specified" do
59
+ setup do
60
+ @bot = Twibot::Bot.new(@config = Twibot::Config.default)
61
+ @bot.stubs(:prompt?).returns(false)
62
+ @bot.stubs(:twitter).returns(stub)
63
+ @bot.stubs(:processed).returns(stub)
64
+
65
+ # stop Bot actually starting during tests
66
+ @bot.stubs(:poll)
67
+ end
68
+
69
+ should "not process tweets prior to bot launch if :process option is set to :new" do
70
+ @bot.stubs(:handlers).returns({:tweet => [stub], :reply => []})
71
+
72
+ # Should fetch the latest ID for both messages and tweets
73
+ @bot.twitter.expects(:messages).with(:received, { :count => 1 }).
74
+ returns([stub(:id => (message_id = stub))]).once
75
+ @bot.twitter.expects(:timeline_for).with(:public, { :count => 1 }).
76
+ returns([stub(:id => (tweet_id = stub))]).once
77
+
78
+ # And set them to the since_id value to be used for future polling
79
+ @bot.processed.expects(:[]=).with(:message, message_id)
80
+ @bot.processed.expects(:[]=).with(:tweet, tweet_id)
81
+ @bot.processed.expects(:[]=).with(:reply, tweet_id)
82
+
83
+ @bot.configure { |c| c.process = :new }
84
+ @bot.run!
85
+ end
86
+
87
+ [:all, nil].each do |value|
88
+ should "process all tweets if :process option is set to #{value.inspect}" do
89
+ @bot.twitter.expects(:messages).never
90
+ @bot.twitter.expects(:timeline_for).never
91
+
92
+ # Shout not set the any value for the since_id tweets
93
+ @bot.processed.expects(:[]=).never
94
+
95
+ @bot.configure { |c| c.process = value }
96
+ @bot.run!
97
+ end
98
+ end
99
+
100
+ should "process all tweets after the ID specified in the :process option" do
101
+ tweet_id = 12345
102
+
103
+ @bot.processed.expects(:[]=).with(anything, 12345).times(3)
104
+
105
+ @bot.configure { |c| c.process = tweet_id }
106
+ @bot.run!
107
+ end
108
+
109
+ should "raise exit when the :process option is not recognized" do
110
+ @bot.configure { |c| c.process = "something random" }
111
+ assert_raise(SystemExit) { @bot.run! }
112
+ end
113
+
114
+ end
115
+
116
+ should "receive message" do
117
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error"))
118
+ bot.add_handler(:message, Twibot::Handler.new)
119
+ bot.twitter.expects(:messages).with(:received, {}).returns([twitter_message("cjno", "Hei der!")])
120
+
121
+ assert bot.receive_messages
122
+ end
123
+
124
+ should "remember last received message" do
125
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error"))
126
+ bot.add_handler(:message, Twibot::Handler.new)
127
+ bot.twitter.expects(:messages).with(:received, {}).returns([twitter_message("cjno", "Hei der!")])
128
+ assert_equal 1, bot.receive_messages
129
+
130
+ bot.twitter.expects(:messages).with(:received, { :since_id => 1 }).returns([])
131
+ assert_equal 0, bot.receive_messages
132
+ end
133
+
134
+ should "receive tweet" do
135
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error"))
136
+ bot.add_handler(:tweet, Twibot::Handler.new)
137
+ bot.twitter.expects(:timeline_for).with(:public, {}).returns([tweet("cjno", "Hei der!")])
138
+
139
+ assert_equal 1, bot.receive_tweets
140
+ end
141
+
142
+ should "receive friend tweets if configured" do
143
+ bot = Twibot::Bot.new(Twibot::Config.new({:log_level => "error", :timeline_for => :friends}))
144
+ bot.add_handler(:tweet, Twibot::Handler.new)
145
+ bot.twitter.expects(:timeline_for).with(:friends, {}).returns([tweet("cjno", "Hei der!")])
146
+
147
+ assert_equal 1, bot.receive_tweets
148
+ end
149
+
150
+ should "remember received tweets" do
151
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error"))
152
+ bot.add_handler(:tweet, Twibot::Handler.new)
153
+ bot.twitter.expects(:timeline_for).with(:public, {}).returns([tweet("cjno", "Hei der!")])
154
+ assert_equal 1, bot.receive_tweets
155
+
156
+ bot.twitter.expects(:timeline_for).with(:public, { :since_id => 1 }).returns([])
157
+ assert_equal 0, bot.receive_tweets
158
+ end
159
+
160
+ should "receive reply when tweet starts with login" do
161
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error", :login => "irbno"))
162
+ bot.add_handler(:reply, Twibot::Handler.new)
163
+ bot.twitter.expects(:status).with(:replies, {}).returns([tweet("cjno", "@irbno Hei der!")])
164
+
165
+ assert_equal 1, bot.receive_replies
166
+ end
167
+
168
+ should "remember received replies" do
169
+ bot = Twibot::Bot.new(Twibot::Config.new(:log_level => "error", :login => "irbno"))
170
+ bot.add_handler(:reply, Twibot::Handler.new)
171
+ bot.twitter.expects(:status).with(:replies, {}).returns([tweet("cjno", "@irbno Hei der!")])
172
+ assert_equal 1, bot.receive_replies
173
+
174
+ bot.twitter.expects(:status).with(:replies, { :since_id => 1 }).returns([])
175
+ assert_equal 0, bot.receive_replies
176
+ end
177
+
178
+ should "use public as default timeline method for tweet 'verb'" do
179
+ bot = Twibot::Bot.new(Twibot::Config.default)
180
+ assert_equal :public, bot.instance_eval { @config.to_hash[:timeline_for] }
181
+ end
182
+
183
+ context "sandboxed network errors" do
184
+ should "rescue certain errors" do
185
+ bot = Twibot::Bot.new(Twibot::Config.default)
186
+
187
+ assert_nothing_raised do
188
+ bot.send(:sandbox) { raise Twitter::RESTError.new }
189
+ bot.send(:sandbox) { raise Errno::ECONNRESET.new }
190
+ bot.send(:sandbox) { raise Timeout::Error.new }
191
+ bot.send(:sandbox) { raise EOFError.new }
192
+ bot.send(:sandbox) { raise Errno::ETIMEDOUT.new }
193
+ bot.send(:sandbox) { raise JSON::ParserError.new }
194
+ bot.send(:sandbox) { raise OpenSSL::SSL::SSLError.new }
195
+ bot.send(:sandbox) { raise SystemStackError.new }
196
+ end
197
+ end
198
+
199
+ should "return default value if error is rescued" do
200
+ bot = Twibot::Bot.new(Twibot::Config.default)
201
+ assert_equal(42, bot.send(:sandbox, 42) { raise Twitter::RESTError })
202
+ end
203
+
204
+ should "not return default value when no error was raised" do
205
+ bot = Twibot::Bot.new(Twibot::Config.default)
206
+ assert_equal(65, bot.send(:sandbox, 42) { 65 })
207
+ end
208
+
209
+ should "not swallow unknown errors" do
210
+ bot = Twibot::Bot.new(Twibot::Config.default)
211
+
212
+ assert_raise StandardError do
213
+ bot.send(:sandbox) { raise StandardError.new "Oops!" }
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ class TestBotMacros < Test::Unit::TestCase
220
+ should "provide configure macro" do
221
+ assert respond_to?(:configure)
222
+ end
223
+
224
+ should "yield configuration" do
225
+ Twibot::Macros.bot = Twibot::Bot.new Twibot::Config.default
226
+ bot.prompt = false
227
+
228
+ conf = nil
229
+ assert_nothing_raised { configure { |c| conf = c } }
230
+ assert conf.is_a?(Twibot::Config)
231
+ end
232
+
233
+ should "add handler" do
234
+ Twibot::Macros.bot = Twibot::Bot.new Twibot::Config.default
235
+ bot.prompt = false
236
+
237
+ handler = add_handler(:message, ":command", :from => :cjno)
238
+ assert handler.is_a?(Twibot::Handler), handler.class
239
+ end
240
+
241
+ should "provide twitter macro" do
242
+ assert respond_to?(:twitter)
243
+ assert respond_to?(:client)
244
+ end
245
+
246
+ context "posting replies" do
247
+ should "work with string messages" do
248
+ text = "Hey there"
249
+ status = Twitter::Status.new(:id => 123,
250
+ :text => "Some text",
251
+ :user => Twitter::User.new(:screen_name => "cjno"))
252
+ client.expects(:status).with(:reply, "@cjno #{text}", 123).returns(true)
253
+
254
+ assert post_reply(status, text)
255
+ end
256
+
257
+ should "work with status object messages" do
258
+ reply = Twitter::Status.new :text => "Hey there"
259
+ status = Twitter::Status.new(:id => 123,
260
+ :text => "Some text",
261
+ :user => Twitter::User.new(:screen_name => "cjno"))
262
+ client.expects(:status).with(:reply, "@cjno Hey there", 123).returns(true)
263
+
264
+ assert post_reply(status, reply)
265
+ end
266
+ end
267
+ end
268
+
269
+ class TestBotHandlers < Test::Unit::TestCase
270
+
271
+ should "include handlers" do
272
+ bot = Twibot::Bot.new(Twibot::Config.new)
273
+
274
+ assert_not_nil bot.handlers
275
+ assert_not_nil bot.handlers[:message]
276
+ assert_not_nil bot.handlers[:reply]
277
+ assert_not_nil bot.handlers[:tweet]
278
+ end
279
+
280
+ should "add handler" do
281
+ bot = Twibot::Bot.new(Twibot::Config.new)
282
+ bot.add_handler :message, Twibot::Handler.new
283
+ assert_equal 1, bot.handlers[:message].length
284
+
285
+ bot.add_handler :message, Twibot::Handler.new
286
+ assert_equal 2, bot.handlers[:message].length
287
+
288
+ bot.add_handler :reply, Twibot::Handler.new
289
+ assert_equal 1, bot.handlers[:reply].length
290
+
291
+ bot.add_handler :reply, Twibot::Handler.new
292
+ assert_equal 2, bot.handlers[:reply].length
293
+
294
+ bot.add_handler :tweet, Twibot::Handler.new
295
+ assert_equal 1, bot.handlers[:tweet].length
296
+
297
+ bot.add_handler :tweet, Twibot::Handler.new
298
+ assert_equal 2, bot.handlers[:tweet].length
299
+ end
300
+ end