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