mattmueller-twibot 0.1.7.1
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.rb +87 -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/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 +96 -0
data/History.txt
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
== 0.1.7 / 2009-06-01
|
2
|
+
|
3
|
+
* New feature - choose how Twibot processes incoming tweets on startup
|
4
|
+
(process all, process new [old behaviour], or process from a given ID)
|
5
|
+
Bodaniel Jeanes
|
6
|
+
* Substantially improved error handling. Now survives all common network
|
7
|
+
stability issues
|
8
|
+
* Added a host configuration option. The host name is displayed along all
|
9
|
+
output from Twibot. Currently Twitter4R does nothing with this option,
|
10
|
+
Twibot knowing about it should make it easier to put Twibot/Twitter4R on
|
11
|
+
other services like Laconica instances
|
12
|
+
|
13
|
+
== 0.1.6 / 2009-04-13
|
14
|
+
|
15
|
+
* Fixed configure block not actually working for username and password
|
16
|
+
Bodaniel Jeanes
|
17
|
+
* Minor updates in tests
|
18
|
+
|
19
|
+
== 0.1.5 / 2009-04-12
|
20
|
+
|
21
|
+
* Added support for regular expression routes
|
22
|
+
* Make timeline_for option configurable, ie in config: timeline_for: :public
|
23
|
+
* Fixed bug: Users where unlawfully rejected when their screen name started with
|
24
|
+
a capital letter (Wilco)
|
25
|
+
* Fixed bug: Twibot crashed if there were no handlers registered
|
26
|
+
|
27
|
+
== 0.1.4 / 2009-03-24
|
28
|
+
|
29
|
+
* Removed some warnings
|
30
|
+
* Added error handling to avoid Twibot crashing when Twitter is down (Ben Vandgrift)
|
31
|
+
* Fixed bug: receiving tweets from named users crashed Twibot (Jens Ohlig)
|
32
|
+
|
33
|
+
== 0.1.3 / 2009-03-19
|
34
|
+
|
35
|
+
* Ruby 1.9 support
|
36
|
+
|
37
|
+
== 0.1.2 / 2009-03-18
|
38
|
+
|
39
|
+
* Removed some warnings
|
40
|
+
* Applied patch from Dan Van Derveer fixing a few minor bugs related to the
|
41
|
+
options hash sent to Twitter4R
|
42
|
+
|
43
|
+
== 0.1.1 / 2009-03-15
|
44
|
+
|
45
|
+
* Fixed dependency
|
46
|
+
|
47
|
+
== 0.1.0 / 2009-03-15
|
48
|
+
|
49
|
+
* 1 major enhancement
|
50
|
+
* Birthday!
|
data/Rakefile
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# Look in the tasks/setup.rb file for the various options that can be
|
2
|
+
# configured in this Rakefile. The .rake files in the tasks directory
|
3
|
+
# are where the options are used.
|
4
|
+
|
5
|
+
begin
|
6
|
+
require 'bones'
|
7
|
+
Bones.setup
|
8
|
+
rescue LoadError
|
9
|
+
begin
|
10
|
+
load 'tasks/setup.rb'
|
11
|
+
rescue LoadError
|
12
|
+
raise RuntimeError, '### please install the "bones" gem ###'
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
ensure_in_path 'lib'
|
17
|
+
require 'twibot'
|
18
|
+
|
19
|
+
task :default => 'test:run'
|
20
|
+
|
21
|
+
PROJ.name = 'twibot'
|
22
|
+
PROJ.authors = 'Christian Johansen'
|
23
|
+
PROJ.email = 'christian@cjohansen.no'
|
24
|
+
PROJ.url = 'http://github.com/bjeanes/twibot/'
|
25
|
+
PROJ.version = Twibot::VERSION
|
26
|
+
PROJ.rubyforge.name = 'twibot'
|
27
|
+
PROJ.readme_file = 'Readme.rdoc'
|
28
|
+
PROJ.rdoc.remote_dir = 'twibot'
|
29
|
+
|
30
|
+
depend_on "mbbx6spp-twitter4r", "0.3.1"
|
data/Readme.rdoc
ADDED
@@ -0,0 +1,269 @@
|
|
1
|
+
= Twibot
|
2
|
+
Official URL: http://github.com/cjohansen/twibot/tree/master
|
3
|
+
Christian Johansen (http://www.cjohansen.no)
|
4
|
+
Twitter: @cjno
|
5
|
+
|
6
|
+
== Description
|
7
|
+
|
8
|
+
Twibot (pronounced like "Abbot"), is a Ruby microframework for creating Twitter
|
9
|
+
bots, heavily inspired by Sinatra.
|
10
|
+
|
11
|
+
== Usage
|
12
|
+
|
13
|
+
=== Simple example
|
14
|
+
|
15
|
+
require 'twibot'
|
16
|
+
|
17
|
+
# Receive messages, and tweet them publicly
|
18
|
+
#
|
19
|
+
message do |message, params|
|
20
|
+
post_tweet message
|
21
|
+
end
|
22
|
+
|
23
|
+
# Respond to @replies if they come from the right crowd
|
24
|
+
#
|
25
|
+
reply :from => [:cjno, :irbno] do |message, params|
|
26
|
+
post_reply message, "I agree"
|
27
|
+
end
|
28
|
+
|
29
|
+
# Listen in and log tweets
|
30
|
+
#
|
31
|
+
tweet do |message, params|
|
32
|
+
MyApp.log_tweet(message)
|
33
|
+
end
|
34
|
+
|
35
|
+
# Search for tweets matching a query. The available search operators
|
36
|
+
# are explained here: <http://search.twitter.com/operators>
|
37
|
+
#
|
38
|
+
search "twibot" do |message, params|
|
39
|
+
# do_something
|
40
|
+
end
|
41
|
+
|
42
|
+
# Search for tweets with a hashtag
|
43
|
+
# see: <http://twitter.pbworks.com/Hashtags>
|
44
|
+
#
|
45
|
+
# Note: hashtag is just a convenience wrapper
|
46
|
+
# around search. It will invoke the search
|
47
|
+
# before and after filters.
|
48
|
+
#
|
49
|
+
hashtag "twibot" do |message, params|
|
50
|
+
# do_something
|
51
|
+
end
|
52
|
+
|
53
|
+
# Search for tweets with one of a number of hashtags
|
54
|
+
# see: <http://twitter.pbworks.com/Hashtags>
|
55
|
+
#
|
56
|
+
# Note: hashtags is just an alias to hashtag
|
57
|
+
#
|
58
|
+
hashtags [:twibot, :ruby, "twitter4r"] do |message, params|
|
59
|
+
# do_something
|
60
|
+
end
|
61
|
+
|
62
|
+
# Process any new followers. user_id will be
|
63
|
+
# the user's Numeric id and params will always
|
64
|
+
# be an empty Hash.
|
65
|
+
#
|
66
|
+
# add_friend!(id) is a convenience wrapper around the
|
67
|
+
# twitter4r friendship method. remove_friend!(id)
|
68
|
+
# is also available.
|
69
|
+
#
|
70
|
+
follower do |user_id, params|
|
71
|
+
# keep out the riff-raff...
|
72
|
+
bot.add_friend!(user_id) unless user_id == 890631
|
73
|
+
end
|
74
|
+
|
75
|
+
# add some set-up code that will be called
|
76
|
+
# before each polling cycle. :all is the
|
77
|
+
# default, so it can safely be omitted
|
78
|
+
#
|
79
|
+
before :all do
|
80
|
+
MyApp.log("Started polling at #{Time.now}")
|
81
|
+
end
|
82
|
+
|
83
|
+
# the after hook for the polling cycle gets
|
84
|
+
# passed the number of messages that were
|
85
|
+
# processed
|
86
|
+
#
|
87
|
+
after :all do |message_count|
|
88
|
+
MyApp.log("Finished polling at #{Time.now}. Got #{message_count} messages.")
|
89
|
+
end
|
90
|
+
|
91
|
+
# each action has before and after hooks available:
|
92
|
+
# - follower
|
93
|
+
# - message
|
94
|
+
# - reply
|
95
|
+
# - search
|
96
|
+
# - tweet
|
97
|
+
#
|
98
|
+
# there can be only one before and one after callback
|
99
|
+
# registered for a given type. the callback block
|
100
|
+
# will be called with no arguments.
|
101
|
+
#
|
102
|
+
# Note: hashtag and hashtags are just wrappers around
|
103
|
+
# search and do not have their own hooks. Use the
|
104
|
+
# search hooks when using hashtag or hashtags.
|
105
|
+
#
|
106
|
+
before :message do
|
107
|
+
MyApp.is_processing_a_message = true
|
108
|
+
end
|
109
|
+
|
110
|
+
after :message do
|
111
|
+
MyApp.is_processing_a_message = false
|
112
|
+
end
|
113
|
+
|
114
|
+
after :follower do
|
115
|
+
MyApp.log("I have another follower!")
|
116
|
+
end
|
117
|
+
|
118
|
+
|
119
|
+
=== Running the bot
|
120
|
+
|
121
|
+
To run the bot, simply do:
|
122
|
+
|
123
|
+
ruby bot.rb
|
124
|
+
|
125
|
+
=== Configuration
|
126
|
+
|
127
|
+
Twibot looks for a configuration file in ./config/bot.yml. It should contain
|
128
|
+
atleast:
|
129
|
+
|
130
|
+
login: twitter_login
|
131
|
+
password: twitter_password
|
132
|
+
|
133
|
+
You can also pass configuration as command line arguments:
|
134
|
+
|
135
|
+
ruby bot.rb --login myaccount
|
136
|
+
|
137
|
+
...or configure with Ruby:
|
138
|
+
|
139
|
+
configure do |conf|
|
140
|
+
conf.login = "my_account"
|
141
|
+
do
|
142
|
+
|
143
|
+
If you don't specify login and/or password in any of these ways, Twibot will
|
144
|
+
prompt you for those.
|
145
|
+
|
146
|
+
If you want to change how Twibot is configured, you can setup the bot instance
|
147
|
+
manually and give it only the configuration options you want:
|
148
|
+
|
149
|
+
# Create bot only with default configuration
|
150
|
+
require 'twibot'
|
151
|
+
bot = Twibot::Bot.new(Twibot::Config.default)
|
152
|
+
|
153
|
+
# Application here...
|
154
|
+
|
155
|
+
If you want command line arguments you can do:
|
156
|
+
|
157
|
+
require 'twibot'
|
158
|
+
bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new)
|
159
|
+
|
160
|
+
To disable the buffering of the Twibot log file, set the `log_flush` config
|
161
|
+
option to `true`:
|
162
|
+
|
163
|
+
configure do |conf|
|
164
|
+
conf.log_file = File.join(DAEMON_ROOT, 'log', 'twitterd.log')
|
165
|
+
conf.log_level = "info"
|
166
|
+
conf.log_flush = true
|
167
|
+
end
|
168
|
+
|
169
|
+
=== "Routes"
|
170
|
+
|
171
|
+
Like Sinatra, and other web app frameworks, Twibot supports "routes": patterns
|
172
|
+
to match incoming tweets and messages:
|
173
|
+
|
174
|
+
require 'twibot'
|
175
|
+
|
176
|
+
tweet "time :country :city" do |message,params|
|
177
|
+
time = MyTimeService.lookup(params[:country], params[:city])
|
178
|
+
client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}"
|
179
|
+
end
|
180
|
+
|
181
|
+
You can have several "tweet" blocks (or "message" or "reply"). The first one to
|
182
|
+
match an incoming tweet/message will handle it.
|
183
|
+
|
184
|
+
As of the upcoming 0.1.5/0.2.0, Twibot also supports regular expressions as routes:
|
185
|
+
|
186
|
+
require 'twibot'
|
187
|
+
|
188
|
+
tweet /^time ([^\s]*) ([^\s]*)/ do |message, params|
|
189
|
+
# params is an array of matches when using regexp routes
|
190
|
+
time = MyTimeService.lookup(params[0], params[1])
|
191
|
+
client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}"
|
192
|
+
end
|
193
|
+
|
194
|
+
=== Working with the Twitter API
|
195
|
+
|
196
|
+
The DSL gives you access to your Twitter client instance through "client" (or "twitter"):
|
197
|
+
|
198
|
+
message do
|
199
|
+
twitter.status :post, "Hello world" # Also: client.status :post, "Hello world"
|
200
|
+
end
|
201
|
+
|
202
|
+
== Requirements
|
203
|
+
|
204
|
+
Twitter4r. You'll need atleast 0.3.1, which is currently only available from GitHub.
|
205
|
+
Versions of Twitter4r prior to 0.3.1 does not allow for the since_id parameter to be
|
206
|
+
appended to URLs to the REST API. Twibot needs these to only fetch fresh messages
|
207
|
+
and tweets.
|
208
|
+
|
209
|
+
== Installation
|
210
|
+
|
211
|
+
gem install twibot
|
212
|
+
|
213
|
+
== Is it Ruby 1.9?
|
214
|
+
|
215
|
+
As of Twibot 0.1.3, yes it is! All tests pass, please give feedback from real world
|
216
|
+
usage if you have trouble.
|
217
|
+
|
218
|
+
== Polling
|
219
|
+
|
220
|
+
Twitter pulled the plug on it's xmpp service last year. This means that Twibot backed
|
221
|
+
bots needs to poll the Twitter service to keep up. Twitter has a request limit on 70
|
222
|
+
reqs/hour, so you should configure your bot not to make more than that, else it will
|
223
|
+
fail. You can ask for your bot account to be put on the whitelist which allows you to
|
224
|
+
make 20.000 reqs/hour, and shouldn't be a problem so long as your intentions are good
|
225
|
+
(I think).
|
226
|
+
|
227
|
+
Twibot polls like this:
|
228
|
+
* Poll messages if any message handlers exist
|
229
|
+
* Poll tweets if any tweet or reply handlers exist
|
230
|
+
* Sleep for +interval+ seconds
|
231
|
+
* Go over again
|
232
|
+
|
233
|
+
As long as Twibot finds any messages and/or tweets, the interval stays the same
|
234
|
+
(min_interval configuration switch). If nothing was found however, the interval to
|
235
|
+
sleep is increased by interval_step configuration option. This happens until it
|
236
|
+
reaches max_interval, where it will stay until Twibot finds anything.
|
237
|
+
|
238
|
+
== Contributors
|
239
|
+
|
240
|
+
* Dan Van Derveer (bug fixes) - http://dan.van.derveer.com/
|
241
|
+
* Ben Vandgrift (Twitter downtime error handling) - http://neovore.com/
|
242
|
+
* Jens Ohlig (warnings)
|
243
|
+
* Wilco van Duinkerken (bug fixes) - http://www.sparkboxx.com/
|
244
|
+
* Bodaniel Jeanes (configure block fix) - http://bjeanes.github.com/
|
245
|
+
|
246
|
+
== License
|
247
|
+
|
248
|
+
(The MIT License)
|
249
|
+
|
250
|
+
Copyright (c) 2009 Christian Johansen
|
251
|
+
|
252
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
253
|
+
a copy of this software and associated documentation files (the
|
254
|
+
'Software'), to deal in the Software without restriction, including
|
255
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
256
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
257
|
+
permit persons to whom the Software is furnished to do so, subject to
|
258
|
+
the following conditions:
|
259
|
+
|
260
|
+
The above copyright notice and this permission notice shall be
|
261
|
+
included in all copies or substantial portions of the Software.
|
262
|
+
|
263
|
+
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
264
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
265
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
266
|
+
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
267
|
+
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
268
|
+
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
269
|
+
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/lib/hash.rb
ADDED
data/lib/twibot.rb
ADDED
@@ -0,0 +1,87 @@
|
|
1
|
+
require 'time'
|
2
|
+
require 'twitter'
|
3
|
+
require 'twitter/client'
|
4
|
+
require 'yaml'
|
5
|
+
require File.join(File.dirname(__FILE__), 'hash')
|
6
|
+
|
7
|
+
module Twibot
|
8
|
+
|
9
|
+
# :stopdoc:
|
10
|
+
VERSION = '0.1.7'
|
11
|
+
LIBPATH = ::File.expand_path(::File.dirname(__FILE__)) + ::File::SEPARATOR
|
12
|
+
PATH = ::File.dirname(LIBPATH) + ::File::SEPARATOR
|
13
|
+
# :startdoc:
|
14
|
+
|
15
|
+
# Returns the version string for the library.
|
16
|
+
#
|
17
|
+
def self.version
|
18
|
+
VERSION
|
19
|
+
end
|
20
|
+
|
21
|
+
# Returns the library path for the module. If any arguments are given,
|
22
|
+
# they will be joined to the end of the libray path using
|
23
|
+
# <tt>File.join</tt>.
|
24
|
+
#
|
25
|
+
def self.libpath( *args )
|
26
|
+
args.empty? ? LIBPATH : ::File.join(LIBPATH, args.flatten)
|
27
|
+
end
|
28
|
+
|
29
|
+
# Returns the lpath for the module. If any arguments are given,
|
30
|
+
# they will be joined to the end of the path using
|
31
|
+
# <tt>File.join</tt>.
|
32
|
+
#
|
33
|
+
def self.path( *args )
|
34
|
+
args.empty? ? PATH : ::File.join(PATH, args.flatten)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Utility method used to require all files ending in .rb that lie in the
|
38
|
+
# directory below this file that has the same name as the filename passed
|
39
|
+
# in. Optionally, a specific _directory_ name can be passed in such that
|
40
|
+
# the _filename_ does not have to be equivalent to the directory.
|
41
|
+
#
|
42
|
+
def self.require_all_libs_relative_to( fname, dir = nil )
|
43
|
+
dir ||= File.basename(fname, '.*')
|
44
|
+
search_me = File.expand_path(File.join(File.dirname(fname), dir, '**', '*.rb'))
|
45
|
+
Dir.glob(search_me).sort.each {|rb| require rb }
|
46
|
+
end
|
47
|
+
|
48
|
+
@@app_file = lambda do
|
49
|
+
ignore = [
|
50
|
+
/lib\/twibot.*\.rb/, # Library
|
51
|
+
/\(.*\)/, # Generated code
|
52
|
+
/custom_require\.rb/ # RubyGems require
|
53
|
+
]
|
54
|
+
|
55
|
+
path = caller.map { |line| line.split(/:\d/, 2).first }.find do |file|
|
56
|
+
next if ignore.any? { |pattern| file =~ pattern }
|
57
|
+
file
|
58
|
+
end
|
59
|
+
|
60
|
+
path || $0
|
61
|
+
end.call
|
62
|
+
|
63
|
+
#
|
64
|
+
# File name of the application file. Inspired by Sinatra
|
65
|
+
#
|
66
|
+
def self.app_file
|
67
|
+
@@app_file
|
68
|
+
end
|
69
|
+
|
70
|
+
#
|
71
|
+
# Runs application if application file is the script being executed
|
72
|
+
#
|
73
|
+
def self.run?
|
74
|
+
self.app_file == $0
|
75
|
+
end
|
76
|
+
|
77
|
+
end # module Twibot
|
78
|
+
|
79
|
+
Twitter::Client.configure do |config|
|
80
|
+
config.application_name = 'Twibot'
|
81
|
+
config.application_version = Twibot.version
|
82
|
+
config.application_url = 'http://github.com/cjohansen/twibot'
|
83
|
+
end
|
84
|
+
|
85
|
+
Twibot.require_all_libs_relative_to(__FILE__)
|
86
|
+
|
87
|
+
# EOF
|
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
|