bjeanes-twibot 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
data/History.txt ADDED
@@ -0,0 +1,24 @@
1
+ == 0.1.4 / 2009-03-24
2
+
3
+ * Removed some warnings
4
+ * Added error handling to avoid Twibot crashing when Twitter is down (Ben Vandgrift)
5
+ * Fixed bug: receiving tweets from named users crashed Twibot (Jens Ohlig)
6
+
7
+ == 0.1.3 / 2009-03-19
8
+
9
+ * Ruby 1.9 support
10
+
11
+ == 0.1.2 / 2009-03-18
12
+
13
+ * Removed some warnings
14
+ * Applied patch from Dan Van Derveer fixing a few minor bugs related to the
15
+ options hash sent to Twitter4R
16
+
17
+ == 0.1.1 / 2009-03-15
18
+
19
+ * Fixed dependency
20
+
21
+ == 0.1.0 / 2009-03-15
22
+
23
+ * 1 major enhancement
24
+ * 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,175 @@
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_tweet "@#{message.sender.screen_name} 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
+ === Running the bot
36
+
37
+ To run the bot, simply do:
38
+
39
+ ruby bot.rb
40
+
41
+ === Configuration
42
+
43
+ Twibot looks for a configuration file in ./config/bot.yml. It should contain
44
+ atleast:
45
+
46
+ login: twitter_login
47
+ password: twitter_password
48
+
49
+ You can also pass configuration as command line arguments:
50
+
51
+ ruby bot.rb --login myaccount
52
+
53
+ ...or configure with Ruby:
54
+
55
+ configure do |conf|
56
+ conf.login = "my_account"
57
+ do
58
+
59
+ If you don't specify login and/or password in any of these ways, Twibot will
60
+ prompt you for those.
61
+
62
+ If you want to change how Twibot is configured, you can setup the bot instance
63
+ manually and give it only the configuration options you want:
64
+
65
+ # Create bot only with default configuration
66
+ require 'twibot'
67
+ bot = Twibot::Bot.new(Twibot::Config.default)
68
+
69
+ # Application here...
70
+
71
+ If you want command line arguments you can do:
72
+
73
+ require 'twibot'
74
+ bot = Twibot::Bot.new(Twibot::Config.default << Twibot::CliConfig.new)
75
+
76
+ === "Routes"
77
+
78
+ Like Sinatra, and other web app frameworks, Twibot supports "routes": patterns
79
+ to match incoming tweets and messages:
80
+
81
+ require 'twibot'
82
+
83
+ tweet "time :country :city" do |message,params|
84
+ time = MyTimeService.lookup(params[:country], params[:city])
85
+ client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}"
86
+ end
87
+
88
+ You can have several "tweet" blocks (or "message" or "reply"). The first one to
89
+ match an incoming tweet/message will handle it.
90
+
91
+ As of the upcoming 0.1.5/0.2.0, Twibot also supports regular expressions as routes:
92
+
93
+ require 'twibot'
94
+
95
+ tweet /^time ([^\s]*) ([^\s]*)/ do |message, params|
96
+ # params is an array of matches when using regexp routes
97
+ time = MyTimeService.lookup(params[0], params[1])
98
+ client.message :post, "Time is #{time} in #{params[:city]}, #{params[:country]}"
99
+ end
100
+
101
+ === Working with the Twitter API
102
+
103
+ The DSL gives you access to your Twitter client instance through "client" (or "twitter"):
104
+
105
+ message do
106
+ twitter.status :post, "Hello world" # Also: client.status :post, "Hello world"
107
+ end
108
+
109
+ == Requirements
110
+
111
+ Twitter4r. You'll need atleast 0.3.1, which is currently only available from GitHub.
112
+ Versions of Twitter4r prior to 0.3.1 does not allow for the since_id parameter to be
113
+ appended to URLs to the REST API. Twibot needs these to only fetch fresh messages
114
+ and tweets.
115
+
116
+ == Installation
117
+
118
+ gem install twibot
119
+
120
+ == Is it Ruby 1.9?
121
+
122
+ As of Twibot 0.1.3, yes it is! All tests pass, please give feedback from real world
123
+ usage if you have trouble.
124
+
125
+ == Polling
126
+
127
+ Twitter pulled the plug on it's xmpp service last year. This means that Twibot backed
128
+ bots needs to poll the Twitter service to keep up. Twitter has a request limit on 70
129
+ reqs/hour, so you should configure your bot not to make more than that, else it will
130
+ fail. You can ask for your bot account to be put on the whitelist which allows you to
131
+ make 20.000 reqs/hour, and shouldn't be a problem so long as your intentions are good
132
+ (I think).
133
+
134
+ Twibot polls like this:
135
+ * Poll messages if any message handlers exist
136
+ * Poll tweets if any tweet or reply handlers exist
137
+ * Sleep for +interval+ seconds
138
+ * Go over again
139
+
140
+ As long as Twibot finds any messages and/or tweets, the interval stays the same
141
+ (min_interval configuration switch). If nothing was found however, the interval to
142
+ sleep is increased by interval_step configuration option. This happens until it
143
+ reaches max_interval, where it will stay until Twibot finds anything.
144
+
145
+ == Contributors
146
+
147
+ * Dan Van Derveer (bug fixes) - http://dan.van.derveer.com/
148
+ * Ben Vandgrift (Twitter downtime error handling) - http://neovore.com/
149
+ * Jens Ohlig (warnings)
150
+ * Wilco van Duinkerken (bug fixes) - http://www.sparkboxx.com/
151
+
152
+ == License
153
+
154
+ (The MIT License)
155
+
156
+ Copyright (c) 2009 Christian Johansen
157
+
158
+ Permission is hereby granted, free of charge, to any person obtaining
159
+ a copy of this software and associated documentation files (the
160
+ 'Software'), to deal in the Software without restriction, including
161
+ without limitation the rights to use, copy, modify, merge, publish,
162
+ distribute, sublicense, and/or sell copies of the Software, and to
163
+ permit persons to whom the Software is furnished to do so, subject to
164
+ the following conditions:
165
+
166
+ The above copyright notice and this permission notice shall be
167
+ included in all copies or substantial portions of the Software.
168
+
169
+ THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
170
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
171
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
172
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
173
+ CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
174
+ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
175
+ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/lib/hash.rb ADDED
@@ -0,0 +1,8 @@
1
+ class Hash
2
+ def symbolize_keys!
3
+ replace(inject({}) do |hash,(key,value)|
4
+ hash[key.to_sym] = value.is_a?(Hash) ? value.symbolize_keys! : value
5
+ hash
6
+ end)
7
+ end
8
+ end
data/lib/twibot/bot.rb ADDED
@@ -0,0 +1,231 @@
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
+ }
34
+ end
35
+
36
+ def twitter
37
+ @twitter ||= Twitter::Client.new :login => config[:login], :password => config[:password]
38
+ end
39
+
40
+ #
41
+ # Run application
42
+ #
43
+ def run!
44
+ puts "Twibot #{Twibot::VERSION} imposing as @#{login}"
45
+
46
+ trap(:INT) do
47
+ puts "\nAnd it's a wrap. See ya soon!"
48
+ exit
49
+ end
50
+
51
+ # Make sure we don't process messages and tweets received prior to bot launch
52
+ messages = twitter.messages(:received, { :count => 1 })
53
+ processed[:message] = messages.first.id if messages.length > 0
54
+
55
+ handle_tweets = !@handlers.nil? && @handlers[:tweet].length + @handlers[:reply].length > 0
56
+ tweets = []
57
+
58
+ begin
59
+ tweets = handle_tweets ? twitter.timeline_for(config[:timeline_for], { :count => 1 }) : []
60
+ rescue Twitter::RESTError => e
61
+ log.error("Failed to connect to Twitter. It's likely down for a bit:")
62
+ log.error(e.to_s)
63
+ end
64
+
65
+ processed[:tweet] = tweets.first.id if tweets.length > 0
66
+ processed[:reply] = tweets.first.id if tweets.length > 0
67
+
68
+ poll
69
+ end
70
+
71
+ #
72
+ # Poll Twitter API in a loop and pass on messages and tweets when they appear
73
+ #
74
+ def poll
75
+ max = max_interval
76
+ step = interval_step
77
+ interval = min_interval
78
+
79
+ while !@abort do
80
+ message_count = 0
81
+ message_count += receive_messages || 0
82
+ message_count += receive_replies || 0
83
+ message_count += receive_tweets || 0
84
+
85
+ interval = message_count > 0 ? min_interval : [interval + step, max].min
86
+
87
+ log.debug "Sleeping for #{interval}s"
88
+ sleep interval
89
+ end
90
+ end
91
+
92
+ #
93
+ # Receive direct messages
94
+ #
95
+ def receive_messages
96
+ type = :message
97
+ return false unless handlers[type].length > 0
98
+ options = {}
99
+ options[:since_id] = processed[type] if processed[type]
100
+ begin
101
+ dispatch_messages(type, twitter.messages(:received, options), %w{message messages})
102
+ rescue Twitter::RESTError => e
103
+ log.error("Failed to connect to Twitter. It's likely down for a bit:")
104
+ log.error(e.to_s)
105
+ 0
106
+ end
107
+ end
108
+
109
+ #
110
+ # Receive tweets
111
+ #
112
+ def receive_tweets
113
+ type = :tweet
114
+ return false unless handlers[type].length > 0
115
+ options = {}
116
+ options[:since_id] = processed[type] if processed[type]
117
+ begin
118
+ dispatch_messages(type,
119
+ twitter.timeline_for(config[:include_friends] ? :friends : :me,
120
+ options), %w{tweet tweets})
121
+ rescue Twitter::RESTError => e
122
+ log.error("Failed to connect to Twitter. It's likely down for a bit:")
123
+ log.error(e.to_s)
124
+ 0
125
+ end
126
+ end
127
+
128
+ #
129
+ # Receive tweets that start with @<login>
130
+ #
131
+ def receive_replies
132
+ type = :reply
133
+ return false unless handlers[type].length > 0
134
+ options = {}
135
+ options[:since_id] = processed[type] if processed[type]
136
+ begin
137
+ dispatch_messages(type, twitter.status(:replies, options), %w{reply replies})
138
+ rescue Twitter::RESTError => e
139
+ log.error("Failed to connect to Twitter. It's likely down for a bit:")
140
+ log.error(e.to_s)
141
+ 0
142
+ end
143
+
144
+ end
145
+
146
+ #
147
+ # Dispatch a collection of messages
148
+ #
149
+ def dispatch_messages(type, messages, labels)
150
+ messages.each { |message| dispatch(type, message) }
151
+ # Avoid picking up messages over again
152
+ processed[type] = messages.first.id if messages.length > 0
153
+
154
+ num = messages.length
155
+ log.info "Received #{num} #{num == 1 ? labels[0] : labels[1]}"
156
+ num
157
+ end
158
+
159
+ #
160
+ # Return logger instance
161
+ #
162
+ def log
163
+ return @log if @log
164
+ os = config[:log_file] ? File.open(config[:log_file], "a") : $stdout
165
+ @log = Logger.new(os)
166
+ @log.level = Logger.const_get(config[:log_level] ? config[:log_level].upcase : "INFO")
167
+ @log
168
+ end
169
+
170
+ #
171
+ # Configure bot
172
+ #
173
+ def configure
174
+ yield @config
175
+ @conf = nil
176
+ @twitter = nil
177
+ end
178
+
179
+ private
180
+ #
181
+ # Map configuration settings
182
+ #
183
+ def method_missing(name, *args, &block)
184
+ return super unless config.key?(name)
185
+
186
+ self.class.send(:define_method, name) { config[name] }
187
+ config[name]
188
+ end
189
+
190
+ #
191
+ # Return configuration
192
+ #
193
+ def config
194
+ return @conf if @conf
195
+ @conf = @config.to_hash
196
+
197
+ if prompt? && (!@conf[:login] || !@conf[:password])
198
+ # No need to rescue LoadError - if the gem is missing then config will
199
+ # be incomplete, something which will be detected elsewhere
200
+ begin
201
+ require 'highline'
202
+ hl = HighLine.new
203
+
204
+ @config.login = hl.ask("Twitter login: ") unless @conf[:login]
205
+ @config.password = hl.ask("Twitter password: ") { |q| q.echo = '*' } unless @conf[:password]
206
+ @conf = @config.to_hash
207
+ rescue LoadError
208
+ raise SystemExit.new( <<-HELP
209
+ Unable to continue without login and password. Do one of the following:
210
+ 1) Install the HighLine gem (gem install highline) to be prompted for credentials
211
+ 2) Create a config/bot.yml with login: and password:
212
+ 3) Put a configure { |conf| conf.login = "..." } block in your bot application
213
+ 4) Run bot with --login and --password options
214
+ HELP
215
+ )
216
+ end
217
+ end
218
+
219
+ @conf
220
+ end
221
+ end
222
+ end
223
+
224
+ # Expose DSL
225
+ include Twibot::Macros
226
+
227
+ # Run bot if macros has been used
228
+ at_exit do
229
+ raise $! if $!
230
+ @@bot.run! if run?
231
+ end
@@ -0,0 +1,138 @@
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
+ :min_interval => 30,
24
+ :max_interval => 300,
25
+ :interval_step => 10,
26
+ :log_level => "info",
27
+ :log_file => nil,
28
+ :login => nil,
29
+ :password => nil,
30
+ :prompt => false,
31
+ :daemonize => false,
32
+ :include_friends => false,
33
+ :timeline_for => :public
34
+ }
35
+
36
+ def initialize(settings = {})
37
+ @configs = []
38
+ @settings = settings
39
+ end
40
+
41
+ #
42
+ # Add a configuration object to override given settings
43
+ #
44
+ def add(config)
45
+ @configs << config
46
+ self
47
+ end
48
+
49
+ alias_method :<<, :add
50
+
51
+ #
52
+ # Makes it possible to access configuration settings as attributes
53
+ #
54
+ def method_missing(name, *args, &block)
55
+ regex = /=$/
56
+ attr_name = name.to_s.sub(regex, '').to_sym
57
+ return super if name == attr_name && !@settings.key?(attr_name)
58
+
59
+ if name != attr_name
60
+ @settings[attr_name] = args.first
61
+ end
62
+
63
+ @settings[attr_name]
64
+ end
65
+
66
+ #
67
+ # Merges configurations and returns a hash with all options
68
+ #
69
+ def to_hash
70
+ hash = {}.merge(@settings)
71
+ @configs.each { |conf| hash.merge!(conf.to_hash) }
72
+ hash
73
+ end
74
+
75
+ def self.default
76
+ Config.new({}.merge(DEFAULT))
77
+ end
78
+ end
79
+
80
+ #
81
+ # Configuration from command line
82
+ #
83
+ class CliConfig < Config
84
+
85
+ def initialize(args = $*)
86
+ super()
87
+
88
+ @parser = OptionParser.new do |opts|
89
+ opts.banner += "Usage: #{File.basename(Twibot.app_file)} [options]"
90
+
91
+ opts.on("-m", "--min-interval SECS", Integer, "Minimum poll interval in seconds") { |i| @settings[:min_interval] = i }
92
+ opts.on("-x", "--max-interval SECS", Integer, "Maximum poll interval in seconds") { |i| @settings[:max_interval] = i }
93
+ opts.on("-s", "--interval-step SECS", Integer, "Poll interval step in seconds") { |i| @settings[:interval_step] = i }
94
+ opts.on("-f", "--log-file FILE", "Log file") { |f| @settings[:log_file] = f }
95
+ opts.on("-l", "--log-level LEVEL", "Log level (err, warn, info, debug), default id info") { |l| @settings[:log_level] = l }
96
+ opts.on("-u", "--login LOGIN", "Twitter login") { |l| @settings[:login] = l }
97
+ opts.on("-p", "--password PASSWORD", "Twitter password") { |p| @settings[:password] = p }
98
+ opts.on("-h", "--help", "Show this message") { puts opts; exit }
99
+
100
+ begin
101
+ require 'daemons'
102
+ opts.on("-d", "--daemonize", "Run as background process (Not implemented)") { |t| @settings[:daemonize] = true }
103
+ rescue LoadError
104
+ end
105
+
106
+ end.parse!(args)
107
+ end
108
+ end
109
+
110
+ #
111
+ # Configuration from files
112
+ #
113
+ class FileConfig < Config
114
+
115
+ #
116
+ # Accepts a stream or a file to read configuration from
117
+ # Default is to read configuration from ./config/bot.yml
118
+ #
119
+ # If a stream is passed it is not closed from within the method
120
+ #
121
+ def initialize(fos = File.expand_path("config/bot.yml"))
122
+ stream = fos.is_a?(String) ? File.open(fos, "r") : fos
123
+
124
+ begin
125
+ config = YAML.load(stream.read)
126
+ config.symbolize_keys! if config
127
+ rescue Exception => err
128
+ puts err.message
129
+ puts "Unable to load configuration, aborting"
130
+ exit
131
+ ensure
132
+ stream.close if fos.is_a?(String)
133
+ end
134
+
135
+ super config.is_a?(Hash) ? config : {}
136
+ end
137
+ end
138
+ end