mattmueller-twibot 0.1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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