birdgrinder 0.1.0.0

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.
@@ -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