mrw-uppercut 0.0.1.pre1
Sign up to get free protection for your applications and to get access to all the features.
- data/README.textile +151 -0
- data/VERSION.yml +4 -0
- data/examples/basic_agent.rb +28 -0
- data/examples/personal.rb +42 -0
- data/lib/uppercut.rb +8 -0
- data/lib/uppercut/agent.rb +229 -0
- data/lib/uppercut/base.rb +90 -0
- data/lib/uppercut/conversation.rb +28 -0
- data/lib/uppercut/message.rb +18 -0
- data/lib/uppercut/notifier.rb +64 -0
- data/spec/agent_spec.rb +381 -0
- data/spec/conversation_spec.rb +14 -0
- data/spec/jabber_stub.rb +121 -0
- data/spec/notifier_spec.rb +85 -0
- data/spec/spec_helper.rb +59 -0
- metadata +100 -0
data/README.textile
ADDED
@@ -0,0 +1,151 @@
|
|
1
|
+
h1. Uppercut
|
2
|
+
|
3
|
+
|
4
|
+
h2. Overview
|
5
|
+
|
6
|
+
Uppercut is a little DSL for writing agents and notifiers which you interact with via your Jabber client.
|
7
|
+
|
8
|
+
|
9
|
+
h2. Making an Agent
|
10
|
+
|
11
|
+
You could put together a very simple agent as follows:
|
12
|
+
|
13
|
+
<pre>
|
14
|
+
<code>
|
15
|
+
class BasicAgent < Uppercut::Agent
|
16
|
+
command 'date' do |c|
|
17
|
+
m.send `date`
|
18
|
+
end
|
19
|
+
|
20
|
+
command /^cat (.*)/ do |c, rest|
|
21
|
+
m.send File.read(rest)
|
22
|
+
end
|
23
|
+
|
24
|
+
command 'report' do |c|
|
25
|
+
m.send 'Hostname: ' + `hostname`
|
26
|
+
m.send 'Running as: ' + ENV['USER']
|
27
|
+
end
|
28
|
+
end
|
29
|
+
</code>
|
30
|
+
</pre>
|
31
|
+
|
32
|
+
With the above code, we've created an Agent template of sorts. It responds to three commands: {date cat report}.
|
33
|
+
The block which is passed to "command" should always have at least one paramater. The first parameter will
|
34
|
+
always be an Uppercut::Conversation object, which can be used to respond to the user who sent the input. When passing
|
35
|
+
a regular expression as the first parameter to "command", all of the _captures_ which the pattern match generates
|
36
|
+
will also be passed to the block. This can be seen in the "cat" example above. There is one capture and it is
|
37
|
+
passed in as _rest_.
|
38
|
+
|
39
|
+
Then to actually put the Agent to work...
|
40
|
+
|
41
|
+
<pre>
|
42
|
+
<code>
|
43
|
+
BasicAgent.new('user@server/resource', 'password').listen
|
44
|
+
</code>
|
45
|
+
</pre>
|
46
|
+
|
47
|
+
This creates a new BasicAgent instance and sends it _listen_ to make it start accepting messages.
|
48
|
+
Note that by default when an agent is listening, it ignores all errors. (In the future, I'll have it log them.)
|
49
|
+
|
50
|
+
There are also event callbacks for agents. These events are based on XMPP presence messages. So, as of this writing,
|
51
|
+
the list of allowed callbacks is: signon, signoff, subscribe, unsubscribe, subscription_approval, subscription_denial,
|
52
|
+
status_change, and status_message_change.
|
53
|
+
|
54
|
+
You can use these callbacks as follows:
|
55
|
+
|
56
|
+
<pre>
|
57
|
+
<code>
|
58
|
+
class CoolAgent < Uppercut::Agent
|
59
|
+
on :subscribe do |c|
|
60
|
+
c.send 'Hey dude, thanks for subscribing!'
|
61
|
+
end
|
62
|
+
|
63
|
+
on :signoff do |c|
|
64
|
+
puts "LOG: #{c.to} signed off."
|
65
|
+
end
|
66
|
+
end
|
67
|
+
</code>
|
68
|
+
</pre>
|
69
|
+
|
70
|
+
Some callbacks only work if the user allows the agent to subscribe to their presence notifications. This happens
|
71
|
+
automatically when a user subscribes to your agent. (Depending on their client) they'll be presented with a choice
|
72
|
+
to authorize or deny the subscription. You can catch their answer with the 'subscription_approval' and 'subscription_denial'
|
73
|
+
callbacks. Without their approving the subscription, your 'signon', 'signoff', 'status_change', and 'status_message_change'
|
74
|
+
callbacks will not be fired by them. I suggest using the 'subscription_denial' callback to inform them of that.
|
75
|
+
|
76
|
+
|
77
|
+
h2. Making a Notifier
|
78
|
+
|
79
|
+
<pre>
|
80
|
+
<code>
|
81
|
+
class BasicNotifier < Uppercut::Notifier
|
82
|
+
notifier :error do |m, data|
|
83
|
+
m.to 'tbmcmullen@gmail.com'
|
84
|
+
m.send "Something in your app blew up: #{data}"
|
85
|
+
end
|
86
|
+
end
|
87
|
+
</code>
|
88
|
+
</pre>
|
89
|
+
|
90
|
+
So, we make a new Notifier class and call a _notifier_ block within it. This makes a notifier with the name :error, which sends a message to tbmcmullen@gmail.com when it fires.
|
91
|
+
|
92
|
+
<pre>
|
93
|
+
<code>
|
94
|
+
notifier = BasicNotifier.new('user@server/resource', 'password').connect
|
95
|
+
|
96
|
+
notifier.notify :error, 'Sprocket Error!'
|
97
|
+
</code>
|
98
|
+
</pre>
|
99
|
+
|
100
|
+
The purpose of a Notifier is just to make a multi-purpose event-driven notification system. You could use it notify yourself of errors in your Rails app, or ping you when a new comment arrives on your blog, or ... I dunno, something else.
|
101
|
+
|
102
|
+
The way I intend a Notifier to be used though is with Starling. Take the following example...
|
103
|
+
|
104
|
+
<pre>
|
105
|
+
<code>
|
106
|
+
notifier = BasicNotifier.new('user@server', 'password', :starling => 'localhost:22122', :queue => 'basic')
|
107
|
+
notifier.listen
|
108
|
+
sleep
|
109
|
+
</code>
|
110
|
+
</pre>
|
111
|
+
|
112
|
+
<pre>
|
113
|
+
<code>
|
114
|
+
require 'starling'
|
115
|
+
starling = Starling.new('localhost:22122')
|
116
|
+
starling.set 'basic', :error
|
117
|
+
</code>
|
118
|
+
</pre>
|
119
|
+
|
120
|
+
In that example, we have the same BasicNotifier. Except this time, we provide it with information on which Starling queue on which Starling server to monitor. Meanwhile, in the separate program, we attach to that same Starling server and push a symbol onto that same queue.
|
121
|
+
|
122
|
+
In this case, what happens is BasicNotifier sees that symbol and begins processing it, just as if it had been passed in via the #notify method.
|
123
|
+
|
124
|
+
|
125
|
+
|
126
|
+
h2. Todo
|
127
|
+
|
128
|
+
|
129
|
+
h3. Security
|
130
|
+
|
131
|
+
If you intend to use this on an open Jabber network (read: Google Talk) take some precautions. Agent#new takes an optional list of JIDs that the agent will respond to.
|
132
|
+
|
133
|
+
<pre>
|
134
|
+
<code>
|
135
|
+
BasicAgent.new('user@server/res', 'pw', :roster => ['you@server'])
|
136
|
+
</code>
|
137
|
+
</pre>
|
138
|
+
|
139
|
+
The agent created with the above statement will not respond to messages or subscription requests from anyone other than 'you@server'.
|
140
|
+
|
141
|
+
h3. Features
|
142
|
+
|
143
|
+
Uppercut is currently very thin on features, as it's in its infancy. Here's a brief list of where I'm
|
144
|
+
currently intending to take the project:
|
145
|
+
|
146
|
+
* send files to and receive files from agents
|
147
|
+
* improving the way one writes executables (using the Daemons library is best for the moment)
|
148
|
+
* auto-updating of agents
|
149
|
+
* I swear I'm going to find a use for MUC
|
150
|
+
* allow agents to establish communications on their own, rather than being reactionary (think RSS updates)
|
151
|
+
* ... other stuff that sounds fun to code up ...
|
data/VERSION.yml
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
class BasicNotifier < Uppercut::Notifier
|
2
|
+
notifier :basic do |n, data|
|
3
|
+
n.to 'tyler@codehallow.com'
|
4
|
+
n.send 'Hey kid.'
|
5
|
+
end
|
6
|
+
end
|
7
|
+
|
8
|
+
class BasicAgent < Uppercut::Agent
|
9
|
+
command 'date' do |m|
|
10
|
+
m.send `date`
|
11
|
+
end
|
12
|
+
|
13
|
+
command /^cat (.*)/ do |m, rest|
|
14
|
+
m.send File.read(rest)
|
15
|
+
end
|
16
|
+
|
17
|
+
command 'report' do |m|
|
18
|
+
m.send 'Hostname: ' + `hostname`
|
19
|
+
m.send 'Running as: ' + ENV['USER']
|
20
|
+
end
|
21
|
+
|
22
|
+
command 'dangerous' do |c|
|
23
|
+
c.send "Are you sure?!"
|
24
|
+
c.wait_for do |reply|
|
25
|
+
c.send %w(yes y).include?(reply.downcase) ? "Okay! Done boss!" : "Cancelled!"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
require 'rubygems'
|
2
|
+
require 'uppercut'
|
3
|
+
require 'yahoo-weather'
|
4
|
+
require 'feed_tools'
|
5
|
+
|
6
|
+
class PersonalAgent < Uppercut::Agent
|
7
|
+
def get_weather
|
8
|
+
@weather_client ||= YahooWeather::Client.new
|
9
|
+
@weather_client.lookup_location('94102') # lookup by zipcode
|
10
|
+
end
|
11
|
+
|
12
|
+
def get_news
|
13
|
+
FeedTools::Feed.open('feed://www.nytimes.com/services/xml/rss/nyt/HomePage.xml')
|
14
|
+
end
|
15
|
+
|
16
|
+
command 'weather' do |c, args|
|
17
|
+
weather = get_weather
|
18
|
+
c.send "#{weather.title}\n#{weather.condition.temp} degrees\n#{weather.condition.text}"
|
19
|
+
end
|
20
|
+
|
21
|
+
command 'forecast' do |c, args|
|
22
|
+
response = get_weather
|
23
|
+
msg = "#{response.forecasts[0].day} - #{response.forecasts[0].text}. "
|
24
|
+
msg << "High: #{response.forecasts[0].high} Low: #{response.forecasts[0].low}\n"
|
25
|
+
msg << "#{response.forecasts[1].day} - #{response.forecasts[1].text}. "
|
26
|
+
msg << "High: #{response.forecasts[1].high} Low: #{response.forecasts[1].low}\n"
|
27
|
+
c.send msg
|
28
|
+
end
|
29
|
+
|
30
|
+
command 'news' do |c, args|
|
31
|
+
msg = get_news.items[0, 5].map { |item|
|
32
|
+
"#{item.title}\n#{item.link}"
|
33
|
+
}.join("\n\n")
|
34
|
+
c.send msg
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
if $0 == __FILE__
|
39
|
+
agent = PersonalAgent.new('name@domain.com/PersonalAgent', 'password')
|
40
|
+
agent.listen
|
41
|
+
sleep
|
42
|
+
end
|
data/lib/uppercut.rb
ADDED
@@ -0,0 +1,229 @@
|
|
1
|
+
class Uppercut
|
2
|
+
class Agent < Base
|
3
|
+
VALID_CALLBACKS = [:signon, :signoff, :subscribe, :unsubscribe, :subscription_approval,
|
4
|
+
:subscription_denial, :status_change, :status_message_change]
|
5
|
+
|
6
|
+
class << self
|
7
|
+
# Define a new command for the agent.
|
8
|
+
#
|
9
|
+
# The pattern can be a String or a Regexp. If a String is passed, it
|
10
|
+
# will dispatch this command only on an exact match. A Regexp simply
|
11
|
+
# must match.
|
12
|
+
#
|
13
|
+
# There is always at least one argument sent to the block. The first
|
14
|
+
# is a always an Uppercut::Message object, which can be used to reply
|
15
|
+
# to the sender. The rest of the arguments to the block correspond to
|
16
|
+
# any captures in the pattern Regexp. (Does not apply to String
|
17
|
+
# patterns).
|
18
|
+
def command(pattern, &block)
|
19
|
+
commands.delete_if{|command| command[0] == pattern }
|
20
|
+
commands << [pattern, block]
|
21
|
+
end
|
22
|
+
|
23
|
+
# Define a callback for specific presence events.
|
24
|
+
#
|
25
|
+
# At the moment this is only confirmed to work with :subscribe and :unsubscribe, but it may work with other types as well.
|
26
|
+
# Example:
|
27
|
+
#
|
28
|
+
# on :subscribe do |conversation|
|
29
|
+
# conversation.send "Welcome! Send 'help' for instructions."
|
30
|
+
# end
|
31
|
+
#
|
32
|
+
def on(type, &block)
|
33
|
+
raise 'Not a valid callback' unless VALID_CALLBACKS.include?(type)
|
34
|
+
callbacks[type] = block
|
35
|
+
end
|
36
|
+
|
37
|
+
def commands #:nodoc:
|
38
|
+
@commands ||= []
|
39
|
+
end
|
40
|
+
|
41
|
+
def callbacks #:nodoc:
|
42
|
+
@callbacks ||= {}
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
DEFAULT_OPTIONS = { :connect => true }
|
47
|
+
|
48
|
+
# Create a new instance of an Agent, possibly connecting to the server.
|
49
|
+
#
|
50
|
+
# user should be a String in the form: "user@server/Resource". pw is
|
51
|
+
# simply the password for this account. The final, and optional, argument
|
52
|
+
# is a boolean which controls whether or not it will attempt to connect to
|
53
|
+
# the server immediately. Defaults to true.
|
54
|
+
def initialize(user, pw, options={})
|
55
|
+
options = DEFAULT_OPTIONS.merge(options)
|
56
|
+
|
57
|
+
@user = user
|
58
|
+
@pw = pw
|
59
|
+
connect if options[:connect]
|
60
|
+
listen if options[:listen]
|
61
|
+
|
62
|
+
@allowed_roster = options[:roster]
|
63
|
+
@redirects = {}
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
def inspect #:nodoc:
|
68
|
+
"<Uppercut::Agent #{@user} " +
|
69
|
+
"#{listening? ? 'Listening' : 'Not Listening'}:" +
|
70
|
+
"#{connected? ? 'Connected' : 'Disconnected'}>"
|
71
|
+
end
|
72
|
+
|
73
|
+
# Makes an Agent instance begin listening for incoming messages and
|
74
|
+
# subscription requests.
|
75
|
+
#
|
76
|
+
# Current listen simply eats any errors that occur, in the interest of
|
77
|
+
# keeping the remote agent alive. These should be logged at some point
|
78
|
+
# in the future. Pass debug as true to prevent this behaviour.
|
79
|
+
#
|
80
|
+
# Calling listen fires off a new Thread whose sole purpose is to listen
|
81
|
+
# for new incoming messages and then fire off a new Thread which dispatches
|
82
|
+
# the message to the proper handler.
|
83
|
+
def listen
|
84
|
+
connect unless connected?
|
85
|
+
|
86
|
+
@listen_thread = Thread.new {
|
87
|
+
@client.add_message_callback do |message|
|
88
|
+
log_and_continue do
|
89
|
+
next if message.body.nil?
|
90
|
+
next unless allowed_roster_includes?(message.from)
|
91
|
+
dispatch message
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
@roster ||= Jabber::Roster::Helper.new(@client)
|
96
|
+
@roster.add_presence_callback do |item, old_presence, new_presence|
|
97
|
+
# Callbacks:
|
98
|
+
# post-subscribe initial stuff (oldp == nil)
|
99
|
+
# status change: (oldp.show != newp.show)
|
100
|
+
# status message change: (oldp.status != newp.status)
|
101
|
+
|
102
|
+
log_and_continue do
|
103
|
+
if old_presence.nil? && new_presence.type == :unavailable
|
104
|
+
dispatch_presence :signoff, new_presence
|
105
|
+
elsif old_presence.nil?
|
106
|
+
# do nothing, we don't care
|
107
|
+
elsif old_presence.type == :unavailable && new_presence
|
108
|
+
dispatch_presence :signon, new_presence
|
109
|
+
elsif old_presence.show != new_presence.show
|
110
|
+
dispatch_presence :status_change, new_presence
|
111
|
+
elsif old_presence.status != new_presence.status
|
112
|
+
dispatch_presence :status_message_change, new_presence
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
116
|
+
@roster.add_subscription_request_callback do |item, presence|
|
117
|
+
# Callbacks:
|
118
|
+
# someone tries to subscribe (presence.type == 'subscribe')
|
119
|
+
|
120
|
+
log_and_continue do
|
121
|
+
case presence.type
|
122
|
+
when :subscribe
|
123
|
+
next unless allowed_roster_includes?(presence.from)
|
124
|
+
@roster.accept_subscription(presence.from)
|
125
|
+
@roster.add(presence.from, nil, true)
|
126
|
+
dispatch_presence :subscribe, presence
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
@roster.add_subscription_callback do |item, presence|
|
131
|
+
# Callbacks:
|
132
|
+
# user allows agent to subscribe to them (presence.type == 'subscribed')
|
133
|
+
# user denies agent subscribe request (presence.type == 'unsubscribed')
|
134
|
+
# user unsubscribes from agent (presence.type == 'unsubscribe')
|
135
|
+
|
136
|
+
log_and_continue do
|
137
|
+
case presence.type
|
138
|
+
when :subscribed
|
139
|
+
dispatch_presence :subscription_approval, presence
|
140
|
+
when :unsubscribed
|
141
|
+
# if item.subscription != :from, it's not a denial... it's just an unsub
|
142
|
+
dispatch_presence(:subscription_denial, presence) if item.subscription == :from
|
143
|
+
when :unsubscribe
|
144
|
+
dispatch_presence :unsubscribe, presence
|
145
|
+
end
|
146
|
+
end
|
147
|
+
end
|
148
|
+
sleep
|
149
|
+
}
|
150
|
+
end
|
151
|
+
|
152
|
+
# Stops the Agent from listening to incoming messages.
|
153
|
+
#
|
154
|
+
# Simply kills the thread if it is running.
|
155
|
+
def stop
|
156
|
+
@listen_thread.kill if listening?
|
157
|
+
end
|
158
|
+
|
159
|
+
# True if the Agent is currently listening for incoming messages.
|
160
|
+
def listening?
|
161
|
+
@listen_thread && @listen_thread.alive?
|
162
|
+
end
|
163
|
+
|
164
|
+
def redirect_from(contact, &block)
|
165
|
+
@redirects[contact] ||= []
|
166
|
+
@redirects[contact].push block
|
167
|
+
end
|
168
|
+
|
169
|
+
attr_accessor :allowed_roster, :roster
|
170
|
+
|
171
|
+
private
|
172
|
+
|
173
|
+
def log_and_continue
|
174
|
+
yield
|
175
|
+
rescue => e
|
176
|
+
log e
|
177
|
+
raise if @debug
|
178
|
+
end
|
179
|
+
|
180
|
+
def dispatch(msg)
|
181
|
+
bare_from = msg.from.bare
|
182
|
+
block = @redirects[bare_from].respond_to?(:shift) && @redirects[bare_from].shift
|
183
|
+
return block[msg.body] if block
|
184
|
+
|
185
|
+
captures = nil
|
186
|
+
pair = self.class.commands.detect { |pattern, block| captures = matches?(pattern, msg.body) }
|
187
|
+
if pair
|
188
|
+
block = pair[1]
|
189
|
+
conversation = Conversation.new(msg.from, self)
|
190
|
+
arguments = [ conversation, *captures ][0..block.arity-1]
|
191
|
+
if block.arity < 1
|
192
|
+
conversation.instance_eval(&block)
|
193
|
+
else
|
194
|
+
block.call(*arguments)
|
195
|
+
end
|
196
|
+
end
|
197
|
+
end
|
198
|
+
|
199
|
+
def dispatch_presence(type, presence)
|
200
|
+
return unless self.class.callbacks[type]
|
201
|
+
self.class.callbacks[type].call(Conversation.new(presence.from, self), presence)
|
202
|
+
end
|
203
|
+
|
204
|
+
def __ucDefault(msg)
|
205
|
+
Message.new(msg.from, self).send("I don't know what \"#{msg.body}\" means.")
|
206
|
+
end
|
207
|
+
|
208
|
+
def matches?(pattern, msg)
|
209
|
+
captures = nil
|
210
|
+
case pattern
|
211
|
+
when String
|
212
|
+
captures = [] if pattern == msg
|
213
|
+
when Regexp
|
214
|
+
match_data = pattern.match(msg)
|
215
|
+
captures = match_data.captures if match_data
|
216
|
+
end
|
217
|
+
captures
|
218
|
+
end
|
219
|
+
|
220
|
+
def allowed_roster_includes?(jid)
|
221
|
+
return true unless @allowed_roster
|
222
|
+
|
223
|
+
jid = jid.to_s
|
224
|
+
return true if @allowed_roster.include?(jid)
|
225
|
+
return true if @allowed_roster.include?(jid.sub(/\/[^\/]+$/, ''))
|
226
|
+
end
|
227
|
+
|
228
|
+
end
|
229
|
+
end
|