birdgrinder 0.1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- data/bin/birdgrinder +34 -0
- data/examples/bird_grinder_client.rb +45 -0
- data/lib/bird_grinder/base.rb +134 -0
- data/lib/bird_grinder/cacheable.rb +66 -0
- data/lib/bird_grinder/client.rb +136 -0
- data/lib/bird_grinder/command_handler.rb +84 -0
- data/lib/bird_grinder/console.rb +42 -0
- data/lib/bird_grinder/exceptions.rb +6 -0
- data/lib/bird_grinder/loader.rb +5 -0
- data/lib/bird_grinder/queue_processor.rb +86 -0
- data/lib/bird_grinder/tweeter/search.rb +71 -0
- data/lib/bird_grinder/tweeter/stream_processor.rb +46 -0
- data/lib/bird_grinder/tweeter/streaming.rb +74 -0
- data/lib/bird_grinder/tweeter.rb +234 -0
- data/lib/bird_grinder.rb +29 -0
- data/lib/birdgrinder.rb +1 -0
- data/lib/moneta/basic_file.rb +113 -0
- data/lib/moneta/redis.rb +49 -0
- data/templates/boot.erb +3 -0
- data/templates/debug_handler.erb +13 -0
- data/templates/hello_world_handler.erb +10 -0
- data/templates/rakefile.erb +15 -0
- data/templates/settings.yml.erb +4 -0
- data/templates/setup.erb +40 -0
- data/templates/test_helper.erb +17 -0
- data/test/bird_grinder_test.rb +9 -0
- data/test/test_helper.rb +35 -0
- metadata +140 -0
data/bin/birdgrinder
ADDED
@@ -0,0 +1,34 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
require 'rubygems'
|
3
|
+
require File.join(File.dirname(__FILE__), "..", "lib", "bird_grinder")
|
4
|
+
|
5
|
+
BirdGrinder::Application.processing(ARGV) do |a|
|
6
|
+
|
7
|
+
a.banner = "BirdGrinder v#{BirdGrinder.version}"
|
8
|
+
|
9
|
+
a.generator!
|
10
|
+
|
11
|
+
a.controller! :console, "Starts up a BirdGrinder friendly IRB instance"
|
12
|
+
a.controller! :client, "Controls the current BirdGrinder instance"
|
13
|
+
|
14
|
+
a.option(:force, "force the creation of the application")
|
15
|
+
a.add("create PATH", "Creates a BirdGrinder instance at a specified location") do |path, options|
|
16
|
+
|
17
|
+
path = File.expand_path(path)
|
18
|
+
if File.exists?(path) && !options[:force]
|
19
|
+
die! "The path you tried to use, #{path}, already exists. Please try another or use the --force option"
|
20
|
+
end
|
21
|
+
|
22
|
+
setup_generator path
|
23
|
+
|
24
|
+
folders 'tmp', 'config', 'handlers', 'test'
|
25
|
+
template 'boot.erb', 'config/boot.rb'
|
26
|
+
template 'setup.erb', 'config/setup.rb'
|
27
|
+
template 'settings.yml.erb', 'config/settings.yml'
|
28
|
+
template 'debug_handler.erb', 'handlers/debug_handler.rb'
|
29
|
+
template 'hello_world_handler.erb', 'handlers/hello_world_handler.rb'
|
30
|
+
template 'rakefile.erb', 'Rakefile'
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,45 @@
|
|
1
|
+
require 'redis'
|
2
|
+
require 'json' unless Hash.new.respond_to?(:to_json)
|
3
|
+
|
4
|
+
# An example of using bird grinder with redis
|
5
|
+
# to create a remote tweeting queue. In essence, it
|
6
|
+
# lets you schedule up tweets to be sent by any
|
7
|
+
# app on your system as a part of your birdgrinder
|
8
|
+
# process, making it easier to do responses etc.
|
9
|
+
class BirdGrinderClient
|
10
|
+
class Error < StandardError; end
|
11
|
+
|
12
|
+
@@namespace = 'bg:messages'
|
13
|
+
|
14
|
+
def self.namespace
|
15
|
+
@@namespace
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.namespace=(value)
|
19
|
+
@@namespace = value
|
20
|
+
end
|
21
|
+
|
22
|
+
def initialize(*args)
|
23
|
+
begin
|
24
|
+
@redis = Redis.new(*args)
|
25
|
+
rescue Errno::ECONNREFUSED
|
26
|
+
raise Error, "Unable to connect to Redis"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def dm(user, message)
|
31
|
+
send_action 'dm', [user, message]
|
32
|
+
end
|
33
|
+
|
34
|
+
def tweet(message)
|
35
|
+
send_action 'tweet', [message]
|
36
|
+
end
|
37
|
+
|
38
|
+
def send_action(name, args)
|
39
|
+
@redis.push_tail(@@namespace, {'action' => name.to_s, 'arguments' => args}.to_json)
|
40
|
+
return true
|
41
|
+
rescue Errno::ECONNREFUSED
|
42
|
+
raise Error, "Unable to connect to Redis to store message"
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
@@ -0,0 +1,134 @@
|
|
1
|
+
module BirdGrinder
|
2
|
+
# A generic base for building handlers. It makes
|
3
|
+
# it easy to implement the most common functionality (e.g.,
|
4
|
+
# clients, checking origin and the like) without
|
5
|
+
# having to reinvent the wheel. Typically used
|
6
|
+
# as a handler for Perennial::Dispatchable
|
7
|
+
#
|
8
|
+
# @see http://github.com/Sutto/perennial/blob/master/lib/perennial/dispatchable.rb
|
9
|
+
class Base
|
10
|
+
is :loggable
|
11
|
+
|
12
|
+
cattr_accessor :handler_mapping
|
13
|
+
|
14
|
+
@@handlers = Hash.new do |h,k|
|
15
|
+
h[k] = Hash.new { |h2,k2| h2[k2] = [] }
|
16
|
+
end
|
17
|
+
|
18
|
+
class << self
|
19
|
+
|
20
|
+
# Gets event handlers for a given event.
|
21
|
+
# @param [Symbol] name the name of the event
|
22
|
+
# @return [Array<Proc>] the resultant handlers
|
23
|
+
def event_handlers_for(name)
|
24
|
+
name = name.to_sym
|
25
|
+
handlers = []
|
26
|
+
klass = self
|
27
|
+
while klass != Object
|
28
|
+
handlers += @@handlers[klass][name]
|
29
|
+
klass = klass.superclass
|
30
|
+
end
|
31
|
+
return handlers
|
32
|
+
end
|
33
|
+
|
34
|
+
# Appends a handler for the given event, either as a
|
35
|
+
# block / proc or as a symbol (for a method name) which
|
36
|
+
# will be called when the event is triggered.
|
37
|
+
#
|
38
|
+
# @param [Symbol] name the event name
|
39
|
+
# @param [Symbol] method_name if present, will call the given instance method
|
40
|
+
# @param [Proc] blk the block to call if method_name isn't given
|
41
|
+
def on_event(name, method_name = nil, &blk)
|
42
|
+
blk = proc { self.send(method_name) } if method_name.present?
|
43
|
+
@@handlers[self][name.to_sym] << blk
|
44
|
+
end
|
45
|
+
|
46
|
+
# Registers the current handler instance to be used. If not
|
47
|
+
# registered, events wont be triggered
|
48
|
+
def register!
|
49
|
+
BirdGrinder::Client.register_handler(self.new)
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
attr_accessor :options, :client, :user
|
55
|
+
|
56
|
+
# Handles a message / event from a dispatcher. This triggers
|
57
|
+
# each respective part of the client / lets us act on events.
|
58
|
+
#
|
59
|
+
# @param [Symbol] message the name of the event, e.g. :incoming_mention
|
60
|
+
# @param [Hash, BirdGrinder::Nash] options the options / params for the given event.
|
61
|
+
def handle(message, options)
|
62
|
+
begin
|
63
|
+
setup_details(message, options)
|
64
|
+
h = self.class.event_handlers_for(message)
|
65
|
+
h.each { |handle| self.instance_eval(&handle) }
|
66
|
+
rescue Exception => e
|
67
|
+
raise e if e.is_a?(BirdGrinder::HaltHandlerProcessing)
|
68
|
+
logger.fatal "Exception processing handlers for #{message}:"
|
69
|
+
logger.log_exception(e)
|
70
|
+
ensure
|
71
|
+
reset_details
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
# Tweets a given message.
|
76
|
+
#
|
77
|
+
# @see BirdGrinder::Client#tweet
|
78
|
+
# @see BirdGrinder::Tweeter#tweet
|
79
|
+
def tweet(message, opts = {})
|
80
|
+
@client && @client.tweet(message, opts)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Direct Messages a specific user if the client exists.
|
84
|
+
#
|
85
|
+
# @see BirdGrinder::Client#dm
|
86
|
+
# @see BirdGrinder::Tweeter#dm
|
87
|
+
def dm(user, message, opts = {})
|
88
|
+
@client && @client.dm(user, message, opts)
|
89
|
+
end
|
90
|
+
|
91
|
+
# Replies to the last received message in the correct format.
|
92
|
+
# if the last message direct, it will send a dm otherwise it
|
93
|
+
# will send a tweet with the correct @-prefix and :in_reply_to_status_id
|
94
|
+
# set correctly so twitter users can see what it is replying
|
95
|
+
# to.
|
96
|
+
#
|
97
|
+
# @param [String] message the message to reply with
|
98
|
+
# @see http://github.com/Sutto/perennial/blob/master/lib/perennial/dispatchable.rb
|
99
|
+
def reply(message)
|
100
|
+
message = message.to_s.strip
|
101
|
+
return if @user.blank? || @client.blank? || message.blank?
|
102
|
+
if @last_message_direct
|
103
|
+
@client.dm(@user, message)
|
104
|
+
else
|
105
|
+
opts = {}
|
106
|
+
opts[:in_reply_to_status_id] = @last_message_id.to_s if @last_message_id.present?
|
107
|
+
@client.reply(@user, message, opts)
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
protected
|
112
|
+
|
113
|
+
def reset_details
|
114
|
+
@direct_last_message = true
|
115
|
+
@last_message_origin = nil
|
116
|
+
@last_message_id = nil
|
117
|
+
@options = nil
|
118
|
+
@user = nil
|
119
|
+
end
|
120
|
+
|
121
|
+
def setup_details(message, options)
|
122
|
+
@options = options.to_nash
|
123
|
+
@user = options.user.screen_name if options.user? && options.user.screen_name?
|
124
|
+
@user ||= options.sender_screen_name if options.sender_screen_name?
|
125
|
+
@last_message_direct = (message == :incoming_direct_message)
|
126
|
+
@last_message_id = options.id
|
127
|
+
end
|
128
|
+
|
129
|
+
def halt_handlers!
|
130
|
+
raise BirdGrinder::HaltHandlerProcessing
|
131
|
+
end
|
132
|
+
|
133
|
+
end
|
134
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require 'moneta'
|
2
|
+
require 'moneta/memory'
|
3
|
+
|
4
|
+
module BirdGrinder
|
5
|
+
|
6
|
+
class << self
|
7
|
+
|
8
|
+
# Gets the current cache store in use. Defaults
|
9
|
+
# to Moneta::Memory
|
10
|
+
#
|
11
|
+
# @see http://github.com/wycats/moneta
|
12
|
+
# @see Moneta::Redis
|
13
|
+
# @see Moneta::BasicFile
|
14
|
+
def cache_store
|
15
|
+
@@__cache_store__ ||= Moneta::Memory.new
|
16
|
+
end
|
17
|
+
|
18
|
+
# Sets the cache store to a hash-like object.
|
19
|
+
#
|
20
|
+
# @param [Object] cs the cache store (must be hash-like with #[] and #[]=)
|
21
|
+
def cache_store=(cs)
|
22
|
+
@@__cache_store__ = cs
|
23
|
+
end
|
24
|
+
|
25
|
+
alias use_cache cache_store=
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
module Cacheable
|
30
|
+
|
31
|
+
# Gives the target class cache_set and cache_get
|
32
|
+
# on a class and instance level. triggered by:
|
33
|
+
# include BirdGrinder::Cacheable
|
34
|
+
def self.included(parent)
|
35
|
+
parent.send(:include, Methods)
|
36
|
+
parent.send(:extend, Methods)
|
37
|
+
end
|
38
|
+
|
39
|
+
module Methods
|
40
|
+
|
41
|
+
# Gets the value for the given key from the
|
42
|
+
# cache store if the cache store is set.
|
43
|
+
#
|
44
|
+
# @param [Symbol] key the key to get the value for
|
45
|
+
# @return [Object] the value for the given key
|
46
|
+
# @see BirdGrinder.cache_store
|
47
|
+
def cache_get(key)
|
48
|
+
cs = BirdGrinder.cache_store
|
49
|
+
cs && cs[key.to_s]
|
50
|
+
end
|
51
|
+
|
52
|
+
# Attempts to set the value for a given key in the
|
53
|
+
# current cache_store.
|
54
|
+
#
|
55
|
+
# @param [Symbol] key the key to set the value for
|
56
|
+
# @param [Object] value the value for said key
|
57
|
+
# @see BirdGrinder.cache_store
|
58
|
+
def cache_set(key, value)
|
59
|
+
cs = BirdGrinder.cache_store
|
60
|
+
cs && cs[key.to_s] = value
|
61
|
+
end
|
62
|
+
|
63
|
+
end
|
64
|
+
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
require 'ostruct'
|
2
|
+
|
3
|
+
module BirdGrinder
|
4
|
+
# Glue between BirdGrinder::Tweeter and associated
|
5
|
+
# handlers to make it function in an evented fashion.
|
6
|
+
#
|
7
|
+
# The client basically brings it all together. It acts
|
8
|
+
# as a delegate for the tweeter and converts received
|
9
|
+
# results into dispatchable form for each handler.
|
10
|
+
#
|
11
|
+
# @see BirdGrinder::Tweeter
|
12
|
+
# @see BirdGrinder::Base
|
13
|
+
# @see http://github.com/Sutto/perennial/blob/master/lib/perennial/dispatchable.rb
|
14
|
+
class Client
|
15
|
+
is :loggable, :dispatchable, :cacheable
|
16
|
+
|
17
|
+
cattr_accessor :current
|
18
|
+
attr_reader :tweeter
|
19
|
+
|
20
|
+
# Initializes this client and creates a new, associated
|
21
|
+
# tweeter instance with this client set as the delegate.
|
22
|
+
# Also, for all of this clients handlers it will call
|
23
|
+
# client= if defined.
|
24
|
+
#
|
25
|
+
# Lastly, it updates BirdGrinder::Client.current to point
|
26
|
+
# to itself.
|
27
|
+
#
|
28
|
+
# @see BirdGrinder::Tweeter#initialize
|
29
|
+
# @see http://github.com/Sutto/perennial/blob/master/lib/perennial/dispatchable.rb
|
30
|
+
def initialize
|
31
|
+
logger.debug "Initializing client..."
|
32
|
+
@tweeter = BirdGrinder::Tweeter.new(self)
|
33
|
+
logger.debug "Notifying handlers of the client"
|
34
|
+
handlers.each { |h| h.client = self if h.respond_to?(:client=) }
|
35
|
+
self.current = self
|
36
|
+
end
|
37
|
+
|
38
|
+
# Forwards a given message type (with options) to each handler,
|
39
|
+
# storing the current id if changed.
|
40
|
+
def receive_message(type, options = BirdGrinder::Nash.new)
|
41
|
+
logger.debug "receiving message: #{type.inspect} - #{options.id}"
|
42
|
+
dispatch(type.to_sym, options)
|
43
|
+
update_stored_id_for(type, options.id)
|
44
|
+
end
|
45
|
+
|
46
|
+
# Fetches all direct messages and mentions and also schedules
|
47
|
+
# the next set of updates.
|
48
|
+
#
|
49
|
+
# @todo Schedule future fetch only when others are completed.
|
50
|
+
def update_all
|
51
|
+
fetch :direct_message, :mention
|
52
|
+
update_and_schedule_fetch
|
53
|
+
end
|
54
|
+
|
55
|
+
# Searches for a given query
|
56
|
+
#
|
57
|
+
# @see BirdGrinder::Tweeter#search
|
58
|
+
def search(q, opts = {})
|
59
|
+
@tweeter.search(q, opts)
|
60
|
+
end
|
61
|
+
|
62
|
+
# Tweets some text as the current user
|
63
|
+
#
|
64
|
+
# @see BirdGrinder::Tweeter#tweet
|
65
|
+
def tweet(text, opts = {})
|
66
|
+
@tweeter.tweet(text, opts)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Direct messages a given user with the given text
|
70
|
+
#
|
71
|
+
# @see BirdGrinder::Tweeter#dm
|
72
|
+
def dm(user, text, opts = {})
|
73
|
+
@tweeter.dm(user, text, opts)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Replies to a given user with the given text.
|
77
|
+
#
|
78
|
+
# @see BirdGrinder::Tweeter#reply
|
79
|
+
def reply(user, text, opts = {})
|
80
|
+
@tweeter.reply(user, text, opts)
|
81
|
+
end
|
82
|
+
|
83
|
+
# Starts processing as a new client instance. The main
|
84
|
+
# entry point into the programs event loop.
|
85
|
+
# Once started, will invoke the once_running hook.
|
86
|
+
def self.run
|
87
|
+
logger.info "Preparing to start BirdGrinder"
|
88
|
+
client = self.new
|
89
|
+
EventMachine.run do
|
90
|
+
client.update_all
|
91
|
+
BirdGrinder::Loader.invoke_hooks!(:once_running)
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
# Stops the event loop so the program can be stopped.
|
96
|
+
def self.stop
|
97
|
+
EventMachine.stop_event_loop
|
98
|
+
end
|
99
|
+
|
100
|
+
protected
|
101
|
+
|
102
|
+
def update_and_schedule_fetch
|
103
|
+
@last_run_at ||= Time.now
|
104
|
+
next_run_time = @last_run_at + BirdGrinder::Settings.check_every
|
105
|
+
next_time_spacing = [0, (next_run_time - @last_run_at).to_i].max
|
106
|
+
@last_run_at = Time.now
|
107
|
+
EventMachine.add_timer(next_time_spacing) { update_all }
|
108
|
+
end
|
109
|
+
|
110
|
+
def stored_id_for(type)
|
111
|
+
Integer(cache_get("#{type}-last-id"))
|
112
|
+
rescue ArgumentError
|
113
|
+
return -1
|
114
|
+
end
|
115
|
+
|
116
|
+
def update_stored_id_for(type, id)
|
117
|
+
return if id.blank?
|
118
|
+
last_id = stored_id_for(type)
|
119
|
+
cache_set("#{type}-last-id", id) if last_id.blank? || id > last_id
|
120
|
+
end
|
121
|
+
|
122
|
+
def fetch(*items)
|
123
|
+
items.each do |n|
|
124
|
+
fetch_latest :"#{n}s", :"incoming_#{n}"
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
def fetch_latest(name, type)
|
129
|
+
options = {}
|
130
|
+
id = stored_id_for(type)
|
131
|
+
options[:since_id] = id unless id.blank?
|
132
|
+
@tweeter.send(name, options)
|
133
|
+
end
|
134
|
+
|
135
|
+
end
|
136
|
+
end
|
@@ -0,0 +1,84 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module BirdGrinder
|
4
|
+
# A simple, method to command mapping for handlers.
|
5
|
+
# E.g.
|
6
|
+
#
|
7
|
+
# class X < BirdGrinder::CommandHandler
|
8
|
+
# exposes :hello
|
9
|
+
# def hello(name)
|
10
|
+
# reply "Why hello there yourself!"
|
11
|
+
# end
|
12
|
+
# end
|
13
|
+
#
|
14
|
+
# When registerted, X will look for tweets that are of the form "@bot-name hello"
|
15
|
+
# or direct mentions with "hello" at the start (or if command_prefix is set to,
|
16
|
+
# for example, !, "!hello") and reply.
|
17
|
+
#
|
18
|
+
# Used for implementing the most common cases of bots that respond to commands.
|
19
|
+
class CommandHandler < Base
|
20
|
+
|
21
|
+
class_inheritable_accessor :exposed_methods, :command_prefix
|
22
|
+
self.command_prefix = ""
|
23
|
+
self.exposed_methods = Set.new
|
24
|
+
|
25
|
+
class << self
|
26
|
+
|
27
|
+
# Marks a set of method names as being available
|
28
|
+
# @param [Array<Symbol>] args the method names to expose
|
29
|
+
def exposes(*args)
|
30
|
+
args.each { |name| exposed_methods << name.to_sym }
|
31
|
+
end
|
32
|
+
|
33
|
+
# Gets a regexp for easy matching
|
34
|
+
#
|
35
|
+
# @return [Regexp] BirdGrinder::CommandHandler.command_prefix in regexp-form
|
36
|
+
def prefix_regexp
|
37
|
+
/^#{command_prefix}/
|
38
|
+
end
|
39
|
+
|
40
|
+
end
|
41
|
+
|
42
|
+
# Default events
|
43
|
+
on_event :incoming_mention, :check_for_commands
|
44
|
+
on_event :incoming_direct_message, :check_for_commands
|
45
|
+
|
46
|
+
# Checks in incoming mentions and direct messages for those
|
47
|
+
# that correctly match the format. If it's found, it will
|
48
|
+
# call the given method with the result of the message,
|
49
|
+
# minus the command, as an argument.
|
50
|
+
def check_for_commands
|
51
|
+
data, command = nil, nil
|
52
|
+
if !@last_message_direct
|
53
|
+
logger.debug "Checking for command in mention"
|
54
|
+
split_message = options.text.split(" ", 3)
|
55
|
+
name, command, data = split_message
|
56
|
+
if name.downcase != "@#{BirdGrinder::Settings.username}".downcase
|
57
|
+
logger.debug "Command is a mention but doesn't start with the username"
|
58
|
+
return
|
59
|
+
end
|
60
|
+
else
|
61
|
+
logger.debug "Checking for command in direct message"
|
62
|
+
command, data = options.text.split(" ", 2)
|
63
|
+
end
|
64
|
+
if (command_name = extract_command_name(command)).present?
|
65
|
+
logger.info "Processing command '#{command_name}' for #{user}"
|
66
|
+
send(command_name, data.to_s) if respond_to?(command_name)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Given a prefix, e.g. "!awesome", will return the associated
|
71
|
+
# method name iff it is exposed.
|
72
|
+
#
|
73
|
+
# @param [String] the command to check
|
74
|
+
# @return [Symbol] the resultant method name or nil if not found.
|
75
|
+
def extract_command_name(command)
|
76
|
+
re = self.class.prefix_regexp
|
77
|
+
if command =~ re
|
78
|
+
method_name = command.gsub(re, "").underscore.to_sym
|
79
|
+
return method_name if exposed_methods.include?(method_name)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
end
|
84
|
+
end
|