chewbranca-twibot 0.1.7.2
Sign up to get free protection for your applications and to get access to all the features.
- data/History.txt +50 -0
- data/Rakefile +30 -0
- data/Readme.rdoc +269 -0
- data/lib/hash.rb +8 -0
- data/lib/twibot/bot.rb +422 -0
- data/lib/twibot/config.rb +140 -0
- data/lib/twibot/handlers.rb +122 -0
- data/lib/twibot/macros.rb +101 -0
- data/lib/twibot/tweets.rb +4 -0
- data/lib/twibot.rb +87 -0
- data/test/test_bot.rb +300 -0
- data/test/test_config.rb +89 -0
- data/test/test_handler.rb +191 -0
- data/test/test_hash.rb +34 -0
- data/test/test_helper.rb +44 -0
- data/test/test_twibot.rb +1 -0
- data/twibot.gemspec +38 -0
- metadata +97 -0
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
|