chewbranca-twibot 0.1.7.2

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.
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