chewbranca-twibot 0.1.7.2

Sign up to get free protection for your applications and to get access to all the features.
data/lib/twibot/bot.rb ADDED
@@ -0,0 +1,422 @@
1
+ require 'logger'
2
+ require File.join(File.expand_path(File.dirname(__FILE__)), 'macros')
3
+ require File.join(File.expand_path(File.dirname(__FILE__)), 'handlers')
4
+
5
+ module Twibot
6
+ #
7
+ # Main bot "controller" class
8
+ #
9
+ class Bot
10
+ include Twibot::Handlers
11
+ attr_reader :twitter
12
+ attr_writer :prompt
13
+
14
+ def initialize(options = nil, prompt = false)
15
+ @prompt = prompt
16
+ @conf = nil
17
+ @config = options || Twibot::Config.default << Twibot::FileConfig.new << Twibot::CliConfig.new
18
+ @log = nil
19
+ @abort = false
20
+ rescue Exception => krash
21
+ raise SystemExit.new(krash.message)
22
+ end
23
+
24
+ def prompt?
25
+ @prompt
26
+ end
27
+
28
+ def processed
29
+ @processed ||= {
30
+ :message => nil,
31
+ :reply => nil,
32
+ :tweet => nil,
33
+ :search => {}
34
+ }
35
+ end
36
+
37
+ def twitter
38
+ @twitter ||= Twitter::Client.new(:login => config[:login],
39
+ :password => config[:password],
40
+ :host => config[:host])
41
+ end
42
+
43
+ #
44
+ # Run application
45
+ #
46
+ def run!
47
+ puts "Twibot #{Twibot::VERSION} imposing as @#{login} on #{config[:host]}"
48
+
49
+ trap(:INT) do
50
+ puts "\nAnd it's a wrap. See ya soon!"
51
+ exit
52
+ end
53
+
54
+ case config[:process]
55
+ when :all, nil
56
+ # do nothing so it will fetch ALL
57
+ when :new
58
+ # Make sure we don't process messages and tweets received prior to bot launch
59
+ messages = twitter.messages(:received, { :count => 1 })
60
+ processed[:message] = messages.first.id if messages.length > 0
61
+
62
+ handle_tweets = !handlers.nil? && handlers_for_type(:tweet).length + handlers_for_type(:reply).length + handlers_for_type(:search).keys.length > 0
63
+ # handle_tweets ||= handlers_for_type(:search).keys.length > 0
64
+ tweets = []
65
+
66
+ sandbox do
67
+ tweets = handle_tweets ? twitter.timeline_for(config[:timeline_for], { :count => 1 }) : []
68
+ end
69
+
70
+ processed[:tweet] = tweets.first.id if tweets.length > 0
71
+ processed[:reply] = tweets.first.id if tweets.length > 0
72
+
73
+ # for searches, use latest tweet on public timeline
74
+ #
75
+ if handle_tweets && config[:timeline_for].to_s != "public"
76
+ sandbox { tweets = twitter.timeline_for(:public, { :count => 1 }) }
77
+ end
78
+ if tweets.length > 0
79
+ handlers_for_type(:search).each_key {|q| processed[:search][q] = tweets.first.id }
80
+ end
81
+
82
+ load_followers
83
+
84
+ when Numeric, /\d+/ # a tweet ID to start from
85
+ processed[:tweet] = processed[:reply] = processed[:message] = config[:process]
86
+ handlers[:search].each_key {|q| processed[:search][q] = config[:process] }
87
+ else abort "Unknown process option #{config[:process]}, aborting..."
88
+ end
89
+
90
+ load_friends unless handlers_for_type(:follower).empty?
91
+
92
+ poll
93
+ end
94
+
95
+ #
96
+ # Poll Twitter API in a loop and pass on messages and tweets when they appear
97
+ #
98
+ def poll
99
+ max = max_interval
100
+ step = interval_step
101
+ interval = min_interval
102
+
103
+ while !@abort do
104
+ run_hook :before_all
105
+ message_count = 0
106
+ message_count += receive_messages || 0
107
+ message_count += receive_replies || 0
108
+ message_count += receive_tweets || 0
109
+ message_count += receive_searches || 0
110
+
111
+ receive_followers
112
+
113
+ run_hook :after_all, message_count
114
+
115
+ interval = message_count > 0 ? min_interval : [interval + step, max].min
116
+
117
+ log.debug "#{config[:host]} sleeping for #{interval}s"
118
+ sleep interval
119
+ end
120
+ end
121
+
122
+
123
+ def friend_ids
124
+ @friend_ids ||= {}
125
+ end
126
+
127
+ def add_friend!(user_or_id, only_local=false)
128
+ id = id_for_user_or_id(user_or_id)
129
+ sandbox(0) { twitter.friend(:add, id) } unless only_local
130
+ friend_ids[id] = true
131
+ end
132
+
133
+ def remove_friend!(user_or_id, only_local=false)
134
+ id = id_for_user_or_id(user_or_id)
135
+ sandbox(0) { twitter.friend(:remove, id) } unless only_local
136
+ friend_ids[id] = false
137
+ end
138
+
139
+ def is_friend?(user_or_id)
140
+ !!friend_ids[id_for_user_or_id(user_or_id)]
141
+ end
142
+
143
+ def follower_ids
144
+ @follower_ids ||= {}
145
+ end
146
+
147
+ def add_follower!(user_or_id)
148
+ follower_ids[id_for_user_or_id(user_or_id)] = true
149
+ end
150
+
151
+ def remove_follower!(user_or_id)
152
+ follower_ids[id_for_user_or_id(user_or_id)] = false
153
+ end
154
+
155
+ def is_follower?(user_or_id)
156
+ !!follower_ids[id_for_user_or_id(user_or_id)]
157
+ end
158
+
159
+ def id_for_user_or_id(user_or_id)
160
+ (user_or_id.respond_to?(:screen_name) ? user_or_id.id : user_or_id).to_i
161
+ end
162
+
163
+
164
+ #
165
+ # retrieve a list of friend ids and store it as a Hash
166
+ #
167
+ def load_friends
168
+ sandbox(0) do
169
+ twitter.graph(:friends, config[:login]).each {|id| add_friend!(id, true) }
170
+ end
171
+ end
172
+
173
+ #
174
+ # retrieve a list of friend ids and store it as a Hash
175
+ #
176
+ def load_followers
177
+ sandbox(0) do
178
+ twitter.graph(:followers, config[:login]).each {|id| add_follower!(id) }
179
+ end
180
+ end
181
+
182
+
183
+ #
184
+ # returns a Hash of all registered hooks
185
+ #
186
+ def hooks
187
+ @hooks ||= {}
188
+ end
189
+
190
+ #
191
+ # registers a block to be called at the given +event+
192
+ #
193
+ def add_hook(event, &blk)
194
+ hooks[event.to_sym] = blk
195
+ end
196
+
197
+ #
198
+ # calls the hook method for the +event+ if one has
199
+ # been defined
200
+ #
201
+ def run_hook(event, *args)
202
+ hooks[event.to_sym].call(*args) if hooks[event.to_sym].respond_to? :call
203
+ end
204
+
205
+ #
206
+ # Receive direct messages
207
+ #
208
+ def receive_messages
209
+ type = :message
210
+ return false unless handlers_for_type(type).length > 0
211
+ options = {}
212
+ options[:since_id] = processed[type] if processed[type]
213
+
214
+ sandbox(0) do
215
+ dispatch_messages(type, twitter.messages(:received, options), %w{message messages})
216
+ end
217
+ end
218
+
219
+ #
220
+ # Receive tweets
221
+ #
222
+ def receive_tweets
223
+ type = :tweet
224
+ return false unless handlers_for_type(type).length > 0
225
+ options = {}
226
+ options[:since_id] = processed[type] if processed[type]
227
+
228
+ sandbox(0) do
229
+ dispatch_messages(type, twitter.timeline_for(config.to_hash[:timeline_for] || :public, options), %w{tweet tweets})
230
+ end
231
+ end
232
+
233
+ #
234
+ # Receive tweets that start with @<login>
235
+ #
236
+ def receive_replies
237
+ type = :reply
238
+ return false unless handlers_for_type(type).length > 0
239
+ options = {}
240
+ options[:since_id] = processed[type] if processed[type]
241
+
242
+ sandbox(0) do
243
+ dispatch_messages(type, twitter.status(:replies, options), %w{reply replies})
244
+ end
245
+ end
246
+
247
+ #
248
+ # Receive tweets that match the query parameters
249
+ #
250
+ def receive_searches
251
+ result_count = 0
252
+
253
+ handlers_for_type(:search).each_pair do |query, search_handlers|
254
+ options = { :q => query, :rpp => 100 }
255
+ [:lang, :geocode].each do |param|
256
+ options[param] = search_handlers.first.options[param] if search_handlers.first.options[param]
257
+ end
258
+ options[:since_id] = processed[:search][query] if processed[:search][query]
259
+
260
+ result_count += sandbox(0) do
261
+ dispatch_messages([:search, query], twitter.search(options.merge(options)), %w{tweet tweets}.map {|l| "#{l} for \"#{query}\""})
262
+ end
263
+ end
264
+
265
+ result_count
266
+ end
267
+
268
+ #
269
+ # Receive any new followers
270
+ #
271
+ def receive_followers
272
+ newbies = []
273
+ sandbox(0) do
274
+ twitter.graph(:followers, config[:login]).each {|id| newbies << id unless is_friend?(id) or is_follower?(id) }
275
+ newbies.each do |id|
276
+ add_follower!(id)
277
+ with_hooks(:follower) { handlers_for_type(:follower).each {|h| h.handle(id, {}) } }
278
+ end
279
+ end
280
+ log.info "#{config[:host]}: Received #{newbies.size} new #{newbies.size == 1 ? 'follower' : 'followers'}"
281
+ end
282
+
283
+ #
284
+ # Dispatch a collection of messages
285
+ #
286
+ def dispatch_messages(type, messages, labels)
287
+ messages.each {|message| with_hooks(type) { dispatch(type, message) } }
288
+ # Avoid picking up messages over again
289
+ if type.is_a? Array # [TODO] (mikedemers) this is an ugly hack
290
+ processed[type.first][type.last] = messages.first.id if messages.length > 0
291
+ else
292
+ processed[type] = messages.first.id if messages.length > 0
293
+ end
294
+
295
+ num = messages.length
296
+ log.info "#{config[:host]}: Received #{num} #{num == 1 ? labels[0] : labels[1]}"
297
+ num
298
+ end
299
+
300
+ #
301
+ # invokes the given block, running the before and
302
+ # after hooks for the given type
303
+ #
304
+ def with_hooks(type, &blk)
305
+ event = type.is_a?(Array) ? type.first : type
306
+ run_hook :"before_#{event}"
307
+ value = yield
308
+ run_hook :"after_#{event}"
309
+ value
310
+ end
311
+
312
+ #
313
+ # Return logger instance
314
+ #
315
+ def log
316
+ return @log if @log
317
+ os = config[:log_file] ? File.open(config[:log_file], "a") : $stdout
318
+ os.sync = !!config[:log_flush]
319
+ @log = Logger.new(os)
320
+ @log.level = Logger.const_get(config[:log_level] ? config[:log_level].upcase : "INFO")
321
+ @log
322
+ end
323
+
324
+ #
325
+ # Configure bot
326
+ #
327
+ def configure
328
+ yield @config
329
+ @conf = nil
330
+ @twitter = nil
331
+ end
332
+
333
+ private
334
+ #
335
+ # Map configuration settings
336
+ #
337
+ def method_missing(name, *args, &block)
338
+ return super unless config.key?(name)
339
+
340
+ self.class.send(:define_method, name) { config[name] }
341
+ config[name]
342
+ end
343
+
344
+ #
345
+ # Return configuration
346
+ #
347
+ def config
348
+ return @conf if @conf
349
+ @conf = @config.to_hash
350
+
351
+ if prompt? && (!@conf[:login] || !@conf[:password])
352
+ # No need to rescue LoadError - if the gem is missing then config will
353
+ # be incomplete, something which will be detected elsewhere
354
+ begin
355
+ require 'highline'
356
+ hl = HighLine.new
357
+
358
+ @config.login = hl.ask("Twitter login: ") unless @conf[:login]
359
+ @config.password = hl.ask("Twitter password: ") { |q| q.echo = '*' } unless @conf[:password]
360
+ @conf = @config.to_hash
361
+ rescue LoadError
362
+ raise SystemExit.new( <<-HELP
363
+ Unable to continue without login and password. Do one of the following:
364
+ 1) Install the HighLine gem (gem install highline) to be prompted for credentials
365
+ 2) Create a config/bot.yml with login: and password:
366
+ 3) Put a configure { |conf| conf.login = "..." } block in your bot application
367
+ 4) Run bot with --login and --password options
368
+ HELP
369
+ )
370
+ end
371
+ end
372
+
373
+ @conf
374
+ end
375
+
376
+ #
377
+ # Takes a block and executes it in a sandboxed network environment. It
378
+ # catches and logs most common network connectivity and timeout errors.
379
+ #
380
+ # The method takes an optional parameter. If set, this value will be
381
+ # returned in case an error was raised.
382
+ #
383
+ def sandbox(return_value = nil)
384
+ begin
385
+ return_value = yield
386
+ rescue Twitter::RESTError => e
387
+ log.error("Failed to connect to Twitter. It's likely down for a bit:")
388
+ log.error(e.to_s)
389
+ rescue Errno::ECONNRESET => e
390
+ log.error("Connection was reset")
391
+ log.error(e.to_s)
392
+ rescue Timeout::Error => e
393
+ log.error("Timeout")
394
+ log.error(e.to_s)
395
+ rescue EOFError => e
396
+ log.error(e.to_s)
397
+ rescue Errno::ETIMEDOUT => e
398
+ log.error("Timeout")
399
+ log.error(e.to_s)
400
+ rescue JSON::ParserError => e
401
+ log.error("JSON Parsing error")
402
+ log.error(e.to_s)
403
+ rescue OpenSSL::SSL::SSLError => e
404
+ log.error("SSL error")
405
+ log.error(e.to_s)
406
+ rescue SystemStackError => e
407
+ log.error(e.to_s)
408
+ end
409
+
410
+ return return_value
411
+ end
412
+ end
413
+ end
414
+
415
+ # Expose DSL
416
+ include Twibot::Macros
417
+
418
+ # Run bot if macros has been used
419
+ at_exit do
420
+ raise $! if $!
421
+ @@bot.run! if run?
422
+ end
@@ -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