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