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