birdgrinder 0.1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,42 @@
1
+ require 'readline'
2
+ require 'irb'
3
+ require 'irb/completion'
4
+
5
+ module BirdGrinder
6
+ # A simple controller for bringing up an IRB instance with the birdgrinder
7
+ # environment pre-loaded.
8
+ class Console
9
+
10
+ # Define code here that you want available at the IRB
11
+ # prompt automatically.
12
+ module BaseExtensions
13
+ include BirdGrinder::Loggable
14
+ end
15
+
16
+ def initialize
17
+ setup_irb
18
+ end
19
+
20
+ # Include the base extensions in our top level binding
21
+ # so they can be accessed at the prompt.
22
+ def setup_irb
23
+ # This is a bit hacky, surely there is a better way?
24
+ # e.g. some way to specify which scope irb runs in.
25
+ eval("include BirdGrinder::Console::BaseExtensions", TOPLEVEL_BINDING)
26
+ end
27
+
28
+ # Actually starts IRB
29
+ def run
30
+ puts "Loading BirdGrinder Console..."
31
+ # Trick IRB into thinking it has no arguments.
32
+ ARGV.replace []
33
+ IRB.start
34
+ end
35
+
36
+ # Starts up a new IRB instance with access to birdgrinder features.
37
+ def self.run
38
+ self.new.run
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,6 @@
1
+ module BirdGrinder
2
+ # A generic error related to anything broken in BirdGrinder
3
+ class Error < StandardError; end
4
+ # An error notifying you that username and password are missing from config/settings.yml
5
+ class MissingAuthDetails < Error; end
6
+ end
@@ -0,0 +1,5 @@
1
+ BirdGrinder::Loader.class_eval do
2
+ # Adds a hook so we can trigger events
3
+ # once the client is running.
4
+ define_hook :once_running
5
+ end
@@ -0,0 +1,86 @@
1
+ require 'em-redis'
2
+
3
+ module BirdGrinder
4
+ # When running, the queue processor makes it possible to
5
+ # use a redis queue queue up and dispatch tweets and direct
6
+ # messages from external processes. This is useful since
7
+ # it makes it easy to have 1 outgoing source of tweets,
8
+ # triggered from any external application. Included in
9
+ # examples/bird_grinder_client.rb is a simple example
10
+ # client which uses redis to queue tweets and dms.
11
+ class QueueProcessor
12
+
13
+ cattr_accessor :polling_delay, :namespace, :action_whitelist
14
+ # 10 seconds if queue is empty.
15
+ self.polling_delay = 10
16
+ self.namespace = 'bg:messages'
17
+ self.action_whitelist = ["tweet", "dm"]
18
+
19
+ is :loggable
20
+
21
+ attr_accessor :tweeter
22
+
23
+ # Initializes redis and our tweeter.
24
+ def initialize
25
+ @tweeter = Tweeter.new(self)
26
+ @redis = EM::P::Redis.connect
27
+ end
28
+
29
+ # Attempts to pop and process an item from the front of the queue.
30
+ # Also, it will queue up the next check - if current item was empty,
31
+ # it will happen after a specified delay otherwise it will check now.
32
+ def check_queue
33
+ logger.debug "Checking Redis for outgoing messages"
34
+ @redis.lpop(@@namespace) do |res|
35
+ if res.blank?
36
+ logger.debug "Empty queue, scheduling check in #{@@polling_delay} seconds"
37
+ schedule_check(@@polling_delay)
38
+ else
39
+ logger.debug "Got item, processing and scheduling next check"
40
+ begin
41
+ handle_action Yajl::Parser.parse(res)
42
+ rescue Yajl::ParseError => e
43
+ logger.error "Couldn't parse json: #{e.message}"
44
+ end
45
+ schedule_check
46
+ end
47
+ end
48
+ end
49
+
50
+ # Check the queue.
51
+ #
52
+ # @param [Integer, nil] time the specified delay. If nil, it will be done now.
53
+ def schedule_check(time = nil)
54
+ if time == nil
55
+ check_queue
56
+ else
57
+ EventMachine.add_timer(@@polling_delay) { check_queue }
58
+ end
59
+ end
60
+
61
+ # Processes a given action action - calling handle action
62
+ # if present.
63
+ def process_action(res)
64
+ if res.is_a?(Hash) && res["action"].present?
65
+ handle_action(res["action"], res["arguments"])
66
+ end
67
+ end
68
+
69
+ # Calls the correct method on the tweeter if present
70
+ # and in the whitelist. logs and caught argument errors.
71
+ def handle_action(action, args)
72
+ args ||= []
73
+ @tweeter.send(action, *[*args]) if @@action_whitelist.include?(action)
74
+ rescue ArgumentError
75
+ logger.warn "Incorrect call for #{action} with arguuments #{args}"
76
+ end
77
+
78
+ # Starts the queue processor with an initial check.
79
+ # raises an exception if the reactor isn't running.
80
+ def self.start
81
+ raise "EventMachine must be running" unless EM.reactor_running?
82
+ new.check_queue
83
+ end
84
+
85
+ end
86
+ end
@@ -0,0 +1,71 @@
1
+ module BirdGrinder
2
+ class Tweeter
3
+ class Search
4
+ is :loggable
5
+
6
+ # 30 seconds between searches
7
+ DELAY_SEARCH = 30
8
+
9
+ cattr_accessor :search_base_url
10
+ @@search_base_url = "http://search.twitter.com/"
11
+
12
+ def initialize(parent)
13
+ logger.debug "Initializing Search"
14
+ @parent = parent
15
+ end
16
+
17
+ # Uses the twitter search api to look up a
18
+ # given query. If :repeat is given, it will
19
+ # repeat indefinitely, getting only new messages each
20
+ # iteration.
21
+ #
22
+ # @param [String] query what you wish to search for
23
+ # @param [Hash] opts options for the query string (except for :repeat)
24
+ # @option opts [Boolean] :repeat if present, will repeat indefinitely.
25
+ def search_for(query, opts = {})
26
+ logger.info "Searching for #{query.inspect}"
27
+ opts = opts.dup
28
+ repeat = opts.delete(:repeat)
29
+ perform_search(query, opts) do |response|
30
+ if repeat && response.max_id?
31
+ logger.info "Scheduling next search iteration for #{query.inspect}"
32
+ EM.add_timer(DELAY_SEARCH) do
33
+ opts[:repeat] = true
34
+ opts[:since_id] = response.max_id
35
+ search_for(query, opts)
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ protected
42
+
43
+ def perform_search(query, opts = {}, &blk)
44
+ url = search_base_url / "search.json"
45
+ query_opts = opts.stringify_keys
46
+ query_opts["q"] = query.to_s.strip
47
+ query_opts["rpp"] ||= 100
48
+ http = EventMachine::HttpRequest.new(url).get(:query => query_opts)
49
+ http.callback do
50
+ response = parse_response(http)
51
+ blk.call(response) if blk.present?
52
+ if response.results?
53
+ response.results.each do |result|
54
+ result.type = :search
55
+ @parent.delegate.receive_message(:incoming_search, result)
56
+ end
57
+ end
58
+ end
59
+ end
60
+
61
+ def parse_response(http)
62
+ response = Yajl::Parser.parse(http.response)
63
+ response.to_nash.normalized
64
+ rescue Yajl::ParseError => e
65
+ logger.error "Couldn't parse search response, error:\n#{e.message}"
66
+ BirdGrinder::Nash.new
67
+ end
68
+
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,46 @@
1
+ module BirdGrinder
2
+ class Tweeter
3
+ class StreamProcessor
4
+ is :loggable
5
+
6
+ def initialize(parent, stream_name)
7
+ @parent = parent
8
+ @stream_name = stream_name.to_sym
9
+ setup_parser
10
+ end
11
+
12
+ def receive_chunk(chunk)
13
+ @parser << chunk
14
+ rescue Yajl::ParseError => e
15
+ logger.error "Couldn't parse json: #{e.message}"
16
+ end
17
+
18
+ def process_stream_item(json)
19
+ return if !json.is_a?(Hash)
20
+ processed = json.to_nash.normalized
21
+ processed.type = lookup_type_for_steam_response(processed)
22
+ processed.streaming_source = @stream_name
23
+ logger.info "Processing Stream Tweet #{processed.id}: #{processed.text}"
24
+ @parent.delegate.receive_message(:incoming_stream, processed)
25
+ end
26
+
27
+ protected
28
+
29
+ def lookup_type_for_steam_response(response)
30
+ if response.delete?
31
+ :delete
32
+ elsif response.limit?
33
+ :limit
34
+ else
35
+ :tweet
36
+ end
37
+ end
38
+
39
+ def setup_parser
40
+ @parser = Yajl::Parser.new
41
+ @parser.on_parse_complete = method(:process_stream_item)
42
+ end
43
+
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,74 @@
1
+ require 'bird_grinder/tweeter/stream_processor'
2
+
3
+ module BirdGrinder
4
+ class Tweeter
5
+ # Basic support for the twitter streaming api. Provides
6
+ # access to sample, filter, follow and track. Note that
7
+ # it will dispatch messages as :incoming_stream, with
8
+ # options.streaming_source set to the stream origin.
9
+ class Streaming
10
+ is :loggable
11
+
12
+ cattr_accessor :streaming_base_url, :api_version
13
+ self.streaming_base_url = "http://stream.twitter.com/"
14
+ self.api_version = 1
15
+
16
+ attr_accessor :parent
17
+
18
+ def initialize(parent)
19
+ @parent = parent
20
+ logger.debug "Initializing Streaming Support"
21
+ end
22
+
23
+ # Start processing the sample stream
24
+ #
25
+ # @param [Hash] opts extra options for the query
26
+ def sample(opts = {})
27
+ get(:sample, opts)
28
+ end
29
+
30
+ # Start processing the filter stream
31
+ #
32
+ # @param [Hash] opts extra options for the query
33
+ def filter(opts = {})
34
+ get(:filter, opts)
35
+ end
36
+
37
+ # Start processing the filter stream with a given follow
38
+ # argument.
39
+ #
40
+ # @param [Array] args what to follow, joined with ","
41
+ def follow(*args)
42
+ opts = args.extract_options!
43
+ opts[:follow] = args.join(",")
44
+ opts[:path] = :filter
45
+ get(:follow, opts)
46
+ end
47
+
48
+ # Starts tracking a specific query.
49
+ #
50
+ # @param [Hash] opts extra options for the query
51
+ def track(query, opts = {})
52
+ opts[:track] = query
53
+ opts[:path] = :filter
54
+ get(:track, opts)
55
+ end
56
+
57
+ protected
58
+
59
+ def get(name, opts = {}, attempts = 0)
60
+ logger.debug "Getting stream #{name} w/ options: #{opts.inspect}"
61
+ path = opts.delete(:path)
62
+ processor = StreamProcessor.new(@parent, name)
63
+ http_opts = {
64
+ :on_response => processor.method(:receive_chunk),
65
+ :head => {'Authorization' => @parent.auth_credentials}
66
+ }
67
+ http_opts[:query] = opts if opts.present?
68
+ url = streaming_base_url / api_version.to_s / "statuses" / "#{path || name}.json"
69
+ http = EventMachine::HttpRequest.new(url).get(http_opts)
70
+ end
71
+
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,234 @@
1
+ require 'uri'
2
+
3
+ module BirdGrinder
4
+ # An asynchronous, delegate-based twitter client that uses
5
+ # em-http-request and yajl on the backend. It's built to be fast,
6
+ # minimal and easy to use.
7
+ #
8
+ # The delegate is simply any class - the tweeter will attempt to
9
+ # call receive_message([Symbol], [BirdGrinder::Nash]) every time
10
+ # it processes a message / item of some kind. This in turn makes
11
+ # it easy to process items. Also, it will dispatch both
12
+ # incoming (e.g. :incoming_mention, :incoming_direct_message) and
13
+ # outgoing (e.g. :outgoing_tweet) events.
14
+ #
15
+ # It has support the twitter search api (via #search) and the currently-
16
+ # alpha twitter streaming api (using #streaming) built right in.
17
+ class Tweeter
18
+ is :loggable, :delegateable
19
+
20
+ require 'bird_grinder/tweeter/streaming'
21
+ require 'bird_grinder/tweeter/search'
22
+
23
+ VALID_FETCHES = [:direct_messages, :mentions]
24
+
25
+ cattr_accessor :api_base_url
26
+ self.api_base_url = "http://twitter.com/"
27
+
28
+ attr_reader :auth_credentials
29
+
30
+ # Initializes the tweeter with a given delegate. It will use
31
+ # username and password from your settings file for authorization
32
+ # with twitter.
33
+ #
34
+ # @param [Delegate] delegate the delegate class
35
+ def initialize(delegate)
36
+ check_auth!
37
+ @auth_credentials = [BirdGrinder::Settings.username, BirdGrinder::Settings.password]
38
+ delegate_to delegate
39
+ end
40
+
41
+ # Automates fetching mentions / direct messages at the same time.
42
+ #
43
+ # @param [Array<Symbol>] fetches what to load - :all for all fetches, or names of the fetches otherwise
44
+ def fetch(*fetches)
45
+ options = fetches.extract_options!
46
+ fetches = VALID_FETCHES if fetches == [:all]
47
+ (fetches & VALID_FETCHES).each do |fetch_type|
48
+ send(fetch_type, options.dup)
49
+ end
50
+ end
51
+
52
+ # Tells the twitter api to follow a specific user
53
+ #
54
+ # @param [String] user the screen_name of the user to follow
55
+ # @param [Hash] opts extra options to pass in the query string
56
+ def follow(user, opts = {})
57
+ user = user.to_s.strip
58
+ logger.info "Following '#{user}'"
59
+ post("friendships/create.json", opts.merge(:screen_name => user)) do
60
+ delegate.receive_message(:outgoing_follow, :user => user)
61
+ end
62
+ end
63
+
64
+ # Tells the twitter api to unfollow a specific user
65
+ #
66
+ # @param [String] user the screen_name of the user to unfollow
67
+ # @param [Hash] opts extra options to pass in the query string
68
+ def unfollow(user, opts = {})
69
+ user = user.to_s.strip
70
+ logger.info "Unfollowing '#{user}'"
71
+ post("friendships/destroy.json", opts.merge(:screen_name => user)) do
72
+ delegate.receive_message(:outgoing_unfollow, :user => user)
73
+ end
74
+ end
75
+
76
+ # Updates your current status on twitter with a specific message
77
+ #
78
+ # @param [String] message the contents of your tweet
79
+ # @param [Hash] opts extra options to pass in the query string
80
+ def tweet(message, opts = {})
81
+ message = message.to_s.strip
82
+ logger.debug "Tweeting #{message}"
83
+ post("statuses/update.json", opts.merge(:status => message)) do |json|
84
+ delegate.receive_message(:outgoing_tweet, status_to_args(json))
85
+ end
86
+ end
87
+
88
+ # Sends a direct message to a given user
89
+ #
90
+ # @param [String] user the screen_name of the user you wish to dm
91
+ # @param [String] text the text to send to the user
92
+ # @param [Hash] opts extra options to pass in the query string
93
+ def dm(user, text, opts = {})
94
+ text = text.to_s.strip
95
+ user = user.to_s.strip
96
+ logger.debug "DM'ing #{user}: #{text}"
97
+ post("direct_messages/new.json", opts.merge(:user => user, :text => text)) do
98
+ delegate.receive_message(:outgoing_direct_message, :user => user, :text => text)
99
+ end
100
+ end
101
+
102
+ # Returns an instance of BirdGrinder::Tweeter::Streaming,
103
+ # used for accessing the alpha streaming api for twitter.
104
+ #
105
+ # @see BirdGrinder::Tweeter::Streaming
106
+ def streaming
107
+ @streaming ||= Streaming.new(self)
108
+ end
109
+
110
+ # Uses the twitter search api to look up a given
111
+ # query, with a set of possible options.
112
+ #
113
+ # @param [String] query the query you wish to search for
114
+ # @param [Hash] opts the opts to query, all except :repeat are sent to twitter.
115
+ # @option opts [Boolean] :repeat repeat the query indefinitely, fetching new messages each time
116
+ def search(query, opts = {})
117
+ @search ||= Search.new(self)
118
+ @search.search_for(query, opts)
119
+ end
120
+
121
+ # Sends a correctly-formatted at reply to a given user.
122
+ # If the users screen_name isn't at the start of the tweet,
123
+ # it will be appended accordingly.
124
+ #
125
+ # @param [String] user the user to reply to's screen name
126
+ # @param [String] test the text to reply with
127
+ # @param [Hash] opts the options to pass in the query string
128
+ def reply(user, text, opts = {})
129
+ user = user.to_s.strip
130
+ text = text.to_s.strip
131
+ text = "@#{user} #{text}".strip unless text =~ /^\@#{user}\b/i
132
+ tweet(text, opts)
133
+ end
134
+
135
+ # Asynchronously fetches the current users (as specified by your settings)
136
+ # direct messages from the twitter api
137
+ #
138
+ # @param [Hash] opts options to pass in the query string
139
+ def direct_messages(opts = {})
140
+ logger.debug "Fetching direct messages..."
141
+ get("direct_messages.json", opts) do |dms|
142
+ logger.debug "Fetched a total of #{dms.size} direct message(s)"
143
+ dms.each do |dm|
144
+ delegate.receive_message(:incoming_direct_message, status_to_args(dm, :direct_message))
145
+ end
146
+ end
147
+ end
148
+
149
+ # Asynchronously fetches the current users (as specified by your settings)
150
+ # mentions from the twitter api
151
+ #
152
+ # @param [Hash] opts options to pass in the query string
153
+ def mentions(opts = {})
154
+ logger.debug "Fetching mentions..."
155
+ get("statuses/mentions.json", opts) do |mentions|
156
+ logger.debug "Fetched a total of #{mentions.size} mention(s)"
157
+ mentions.each do |status|
158
+ delegate.receive_message(:incoming_mention, status_to_args(status, :mention))
159
+ end
160
+ end
161
+ end
162
+
163
+ protected
164
+
165
+ def request(path = "/")
166
+ EventMachine::HttpRequest.new(api_base_url / path)
167
+ end
168
+
169
+ def get(path, params = {}, &blk)
170
+ http = request(path).get({
171
+ :head => {'Authorization' => @auth_credentials},
172
+ :query => params.stringify_keys
173
+ })
174
+ add_response_callback(http, blk)
175
+ http
176
+ end
177
+
178
+ def post(path, params = {}, &blk)
179
+ real_params = {}
180
+ params.each_pair { |k,v| real_params[URI.encode(k.to_s)] = URI.encode(v) }
181
+ http = request(path).post({
182
+ :head => {
183
+ 'Authorization' => @auth_credentials,
184
+ 'Content-Type' => 'application/x-www-form-urlencoded'
185
+ },
186
+ :body => real_params
187
+ })
188
+ add_response_callback(http, blk)
189
+ http
190
+ end
191
+
192
+ def add_response_callback(http, blk)
193
+ http.callback do
194
+ res = parse_response(http)
195
+ if res.nil?
196
+ logger.warn "Got back a blank / errored response."
197
+ elsif successful?(res)
198
+ blk.call(res) unless blk.blank?
199
+ else
200
+ logger.eror "Error: #{res.error} (on #{res.request})"
201
+ end
202
+ end
203
+ end
204
+
205
+ def parse_response(http)
206
+ response = Yajl::Parser.parse(http.response)
207
+ if response.respond_to?(:to_ary)
208
+ response.map { |i| i.to_nash }
209
+ else
210
+ response.to_nash
211
+ end
212
+ rescue Yajl::ParseError => e
213
+ logger.error "Invalid Response: #{http.response} (#{e.message})"
214
+ nil
215
+ end
216
+
217
+ def successful?(response)
218
+ response.respond_to?(:to_nash) ? !response.to_nash.error? : true
219
+ end
220
+
221
+ def status_to_args(status_items, type = :tweet)
222
+ results = status_items.to_nash.normalized
223
+ results.type = type
224
+ results
225
+ end
226
+
227
+ def check_auth!
228
+ if BirdGrinder::Settings["username"].blank? || BirdGrinder::Settings["username"].blank?
229
+ raise BirdGrinder::MissingAuthDetails, "Missing twitter username or password."
230
+ end
231
+ end
232
+
233
+ end
234
+ end