robut 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. data/.travis.yml +11 -0
  2. data/Gemfile +2 -3
  3. data/README.rdoc +38 -19
  4. data/examples/Chatfile +10 -4
  5. data/lib/robut.rb +3 -0
  6. data/lib/robut/connection.rb +10 -72
  7. data/lib/robut/plugin.rb +94 -13
  8. data/lib/robut/plugin/echo.rb +3 -13
  9. data/lib/robut/plugin/google_images.rb +17 -0
  10. data/lib/robut/plugin/help.rb +7 -15
  11. data/lib/robut/plugin/meme.rb +22 -53
  12. data/lib/robut/plugin/pick.rb +18 -0
  13. data/lib/robut/plugin/ping.rb +3 -9
  14. data/lib/robut/plugin/quips.rb +60 -0
  15. data/lib/robut/plugin/say.rb +6 -12
  16. data/lib/robut/plugin/stock.rb +45 -0
  17. data/lib/robut/pm.rb +40 -0
  18. data/lib/robut/presence.rb +35 -0
  19. data/lib/robut/room.rb +30 -0
  20. data/lib/robut/version.rb +1 -1
  21. data/test/mocks/connection_mock.rb +2 -18
  22. data/test/mocks/presence_mock.rb +25 -0
  23. data/test/test_helper.rb +3 -1
  24. data/test/unit/connection_test.rb +58 -25
  25. data/test/unit/plugin/alias_test.rb +5 -4
  26. data/test/unit/plugin/echo_test.rb +5 -4
  27. data/test/unit/plugin/help_test.rb +4 -3
  28. data/test/unit/plugin/later_test.rb +7 -6
  29. data/test/unit/plugin/lunch_test.rb +6 -5
  30. data/test/unit/plugin/pick_test.rb +35 -0
  31. data/test/unit/plugin/ping_test.rb +4 -3
  32. data/test/unit/plugin/quips_test.rb +58 -0
  33. data/test/unit/plugin/say_test.rb +4 -3
  34. data/test/unit/plugin/weather_test.rb +15 -14
  35. data/test/unit/plugin_test.rb +62 -1
  36. data/test/unit/room_test.rb +51 -0
  37. metadata +28 -20
  38. data/lib/robut/plugin/rdio.rb +0 -96
  39. data/lib/robut/plugin/rdio/public/css/rdio.css +0 -141
  40. data/lib/robut/plugin/rdio/public/css/style.css +0 -79
  41. data/lib/robut/plugin/rdio/public/css/style_after.css +0 -42
  42. data/lib/robut/plugin/rdio/public/images/background.png +0 -0
  43. data/lib/robut/plugin/rdio/public/images/no-album.png +0 -0
  44. data/lib/robut/plugin/rdio/public/index.html +0 -43
  45. data/lib/robut/plugin/rdio/public/js/libs/dd_belatedpng.js +0 -13
  46. data/lib/robut/plugin/rdio/public/js/libs/jquery-1.5.1.min.js +0 -16
  47. data/lib/robut/plugin/rdio/public/js/libs/modernizr-1.7.min.js +0 -2
  48. data/lib/robut/plugin/rdio/public/js/rdio.js +0 -129
  49. data/lib/robut/plugin/rdio/public/js/script.js +0 -3
  50. data/lib/robut/plugin/rdio/server.rb +0 -57
@@ -0,0 +1,11 @@
1
+ language: ruby
2
+ rvm:
3
+ # - 1.8.7
4
+ - 1.9.2
5
+ - 1.9.3
6
+ # - jruby-18mode # JRuby in 1.8 mode
7
+ # - jruby-19mode # JRuby in 1.9 mode
8
+ # - rbx-18mode
9
+ # - rbx-19mode # currently in active development, may or may not work for your project
10
+ # uncomment this line if your project needs to run something other than `rake`:
11
+ # script: bundle exec rspec spec
data/Gemfile CHANGED
@@ -8,13 +8,12 @@ group :test do
8
8
  gem 'simplecov'
9
9
  gem 'webmock'
10
10
  gem 'time-warp'
11
+ gem 'mocha'
11
12
  end
12
13
 
13
14
  group :plugin do
14
15
  gem 'calc'
15
16
  gem 'twss'
16
- gem 'rdio', '0.0.91' # .92 is horked
17
- gem 'sinatra'
18
17
  gem 'meme_generator'
19
18
  gem 'nokogiri'
20
- end
19
+ end
@@ -1,6 +1,16 @@
1
1
  = Robut
2
2
 
3
- The friendly plugin-enabled HipChat bot.
3
+ The friendly plugin-enabled HipChat bot.
4
+
5
+ == NOTE: Breaking backwards compatibility!
6
+
7
+ A little while ago, HipChat changed the way their mention names
8
+ work. Instead of just using the first name ('@robut'), they now use
9
+ the full name ('@RobutTRobot'). Starting from v0.4.0, robut uses this
10
+ new format by default. If you prefer the old style, you can set it in
11
+ your Chatfile:
12
+
13
+ config.mention_name = 'robut'
4
14
 
5
15
  == Installation and usage
6
16
 
@@ -22,8 +32,11 @@ Some of the included plugins require extra gems to be installed:
22
32
 
23
33
  [Robut::Plugin::TWSS] requires the <tt>twss</tt> gem.
24
34
  [Robut::Plugin::Calc] requires the <tt>calc</tt> gem.
25
- [Robut::Plugin::Rdio] requires the <tt>rdio</tt> and <tt>sinatra</tt> gems.
26
35
  [Robut::Plugin::Weather] requires the <tt>nokogiri</tt> gem.
36
+ [Robut::Plugin::GoogleImages] requires the <tt>google-search</tt> gem.
37
+
38
+ A list of known 3rd-party plugins is available on the wiki: https://github.com/justinweiss/robut/wiki/Robut-Plugins
39
+ Feel free to add your own creations!
27
40
 
28
41
  == The Chatfile
29
42
 
@@ -53,7 +66,7 @@ kind of configuration they support.
53
66
  Some plugins might require storage (like the `lunch` plugin). You can
54
67
  configure the type of storage you want to use based on the need for
55
68
  persistence. The default is the HashStore which is in-memory only. Below
56
- is an example of using the YamlStore.
69
+ is an example of using the YamlStore.
57
70
 
58
71
  Robut::Connection.configure do |config|
59
72
  # ...
@@ -71,13 +84,16 @@ Robut::Connection.configure block:
71
84
  Robut::Connection.configure do |config|
72
85
  config.jid = '...@chat.hipchat.com/bot'
73
86
  config.password = 'password'
74
- config.nick = 'My Nick'
75
- config.room = '...@conf.hipchat.com'
76
-
87
+ config.nick = 'Bot Nick'
88
+ config.rooms = ['1234_room_1@conf.hipchat.com', '1234_room_2@conf.hipchat.com']
89
+
90
+ # Custom @mention name
91
+ config.mention_name = 'Bot'
92
+
77
93
  # Example of the YamlStore which uses a yaml file for persistence
78
94
  Robut::Storage::YamlStore.file = "~/.robut_store"
79
95
  config.store = Robut::Storage::YamlStore
80
-
96
+
81
97
  # Add a logger if you want to debug the connection
82
98
  # config.logger = Logger.new(STDOUT)
83
99
  end
@@ -98,16 +114,19 @@ Robut includes a few plugins that we've found useful:
98
114
  @robut lunch? # => "Banh Mi!"
99
115
 
100
116
 
101
- [Robut::Plugin::Meme] an interface to memecaptain.
117
+ [Robut::Plugin::Meme] generates meme images using memecaptain.
102
118
 
103
119
  @robut meme all_the_things drink; all the beer
104
120
 
121
+ [Robut::Plugin::GoogleImages] does a google image search for a query and returns the first result.
105
122
 
106
- [Robut::Plugin::Sayings] a simple regex listener and responder.
123
+ @robut image ship it
124
+
125
+ [Robut::Plugin::Sayings] a simple regex listener and responder.
107
126
 
108
127
  You're the worst robot ever, @robut. # => I know.
109
128
 
110
- [Robut::Plugin::TWSS] an interface to the TWSS gem. Listens to anything said in the chat room and responds "That's what she said!" where appropriate.
129
+ [Robut::Plugin::TWSS] an interface to the TWSS gem. Listens to anything said in the chat room and responds "That's what she said!" where appropriate.
111
130
 
112
131
  well hurry up, you're not going fast enough # => "That's what she said!"
113
132
 
@@ -117,12 +136,12 @@ Robut includes a few plugins that we've found useful:
117
136
  @robut echo hello world # => "hello world"
118
137
 
119
138
 
120
- [Robut::Plugin::Say] invokes the "say" command (text-to-speech).
139
+ [Robut::Plugin::Say] invokes the "say" command (text-to-speech).
121
140
 
122
141
  @robut say this rocks # (make sure robut is running on a machine with speakers :)
123
142
 
124
143
 
125
- [Robut::Plugin::Ping] responds with "pong".
144
+ [Robut::Plugin::Ping] responds with "pong".
126
145
 
127
146
  @robut ping # => "pong"
128
147
 
@@ -132,11 +151,6 @@ Robut includes a few plugins that we've found useful:
132
151
  @robut in 5 minutes echo @justin wake up! # => (5 minutes later) "@justin wake up!"
133
152
 
134
153
 
135
- [Robut::Plugin::Rdio] uses the Rdio[http://www.rdio.com] API and a simple web server to queue and play songs on Rdio.
136
-
137
- @robut play ok computer
138
-
139
-
140
154
  [Robut::Plugin::Weather] uses Google Weather to fetch for the weather for a given location and day.
141
155
 
142
156
  @robut seattle weather saturday? # => "Forecast for Seattle, WA on Sat: Sunny, High: 77F, Low: 55F"
@@ -146,6 +160,12 @@ Robut includes a few plugins that we've found useful:
146
160
  @robut alias "cowboy" "@robut play bon jovi wanted dead or alive" # Cuz somtimes you need it.
147
161
  @robut alias w weather? # less typing for common stuff
148
162
 
163
+ [Robut::Plugin::Quips] stores and posts Bugzilla-style quips.
164
+
165
+ @robut add quip It was great when I wrote it!
166
+ @robut quip # => It was great when I wrote it!
167
+ @robut remove quip It was great when I wrote it!
168
+
149
169
  [Robut::Plugin::Help] lists usage for all the plugins loaded into robut
150
170
 
151
171
  @robut help # => command usage
@@ -179,5 +199,4 @@ Once your changes are ready:
179
199
 
180
200
  == Todo
181
201
 
182
- * Support connections to multiple rooms
183
- * More plugins!
202
+ * More plugins!
@@ -8,15 +8,21 @@ Robut::Plugin.plugins << Robut::Plugin::TWSS
8
8
 
9
9
  # Configure the robut jabber connection and you're good to go!
10
10
  Robut::Connection.configure do |config|
11
+ # Note that the jid must end with /bot if you don't want robut to
12
+ # spam the channel, as described by the last bullet point on this
13
+ # page: https://www.hipchat.com/help/category/xmpp
11
14
  config.jid = '...@chat.hipchat.com/bot'
12
15
  config.password = 'password'
13
- config.nick = 'My Nick'
16
+ config.nick = 'Bot Nick'
14
17
  config.room = '...@conf.hipchat.com'
15
-
18
+
19
+ # Custom @mention name
20
+ # config.mention_name = 'Bot'
21
+
16
22
  # Some plugins require storage
17
23
  Robut::Storage::YamlStore.file = ".robut"
18
24
  config.store = Robut::Storage::YamlStore
19
-
25
+
20
26
  # Add a logger if you want to debug the connection
21
27
  # config.logger = Logger.new(STDOUT)
22
- end
28
+ end
@@ -1,5 +1,8 @@
1
1
  module Robut
2
2
  autoload :Plugin, 'robut/plugin'
3
+ autoload :Presence, 'robut/presence'
4
+ autoload :Room, 'robut/room'
5
+ autoload :PM, 'robut/pm'
3
6
  autoload :Connection, 'robut/connection'
4
7
  autoload :Storage, 'robut/storage'
5
8
  end
@@ -19,7 +19,7 @@ class Robut::Connection
19
19
  # [+jid+, +password+, +nick+] The HipChat credentials given on
20
20
  # https://www.hipchat.com/account/xmpp
21
21
  #
22
- # [+room+] The chat room to join, in the format <tt>jabber_name</tt>@<tt>conference_server</tt>
22
+ # [+rooms+] The chat room(s) to join, with each in the format <tt>jabber_name</tt>@<tt>conference_server</tt>
23
23
  #
24
24
  # [+logger+] a logger instance to use for debug output.
25
25
  attr_accessor :config
@@ -27,15 +27,15 @@ class Robut::Connection
27
27
  # The Jabber::Client that's connected to the HipChat server.
28
28
  attr_accessor :client
29
29
 
30
- # The MUC that wraps the Jabber Chat protocol.
31
- attr_accessor :muc
32
-
33
30
  # The storage instance that's available to plugins
34
31
  attr_accessor :store
35
32
 
36
33
  # The roster of currently available people
37
34
  attr_accessor :roster
38
35
 
36
+ # The rooms that robut is connected to.
37
+ attr_accessor :rooms
38
+
39
39
  class << self
40
40
  # Class-level config. This is set by the +configure+ class method,
41
41
  # and is used if no configuration is passed to the +initialize+
@@ -65,8 +65,8 @@ class Robut::Connection
65
65
  self.config = _config || self.class.config
66
66
 
67
67
  self.client = Jabber::Client.new(self.config.jid)
68
- self.muc = Jabber::MUC::SimpleMUCClient.new(client)
69
68
  self.store = self.config.store || Robut::Storage::HashStore # default to in-memory store only
69
+ self.config.rooms ||= Array(self.config.room) # legacy support?
70
70
 
71
71
  if self.config.logger
72
72
  Jabber.logger = self.config.logger
@@ -74,43 +74,6 @@ class Robut::Connection
74
74
  end
75
75
  end
76
76
 
77
- # Send +message+ to the room we're currently connected to, or
78
- # directly to the person referenced by +to+. +to+ can be either a
79
- # jid or the string name of the person.
80
- def reply(message, to = nil)
81
- if to
82
- unless to.kind_of?(Jabber::JID)
83
- to = find_jid_by_name(to)
84
- end
85
-
86
- msg = Jabber::Message.new(to || muc.room, message)
87
- msg.type = :chat
88
- client.send(msg)
89
- else
90
- muc.send(Jabber::Message.new(muc.room, message))
91
- end
92
- end
93
-
94
- # Sends the chat message +message+ through +plugins+.
95
- def handle_message(plugins, time, nick, message)
96
- # ignore all messages sent by robut. If you really want robut to
97
- # reply to itself, you can use +fake_message+.
98
- return if nick == config.nick
99
-
100
- plugins.each do |plugin|
101
- begin
102
- rsp = plugin.handle(time, nick, message)
103
- break if rsp == true
104
- rescue => e
105
- reply("UH OH! #{plugin.class.name} just crashed!")
106
- if config.logger
107
- config.logger.error e
108
- config.logger.error e.backtrace.join("\n")
109
- end
110
- end
111
- end
112
- end
113
-
114
77
  # Connects to the specified room with the given credentials, and
115
78
  # enters an infinite loop. Any messages sent to the room will pass
116
79
  # through all the included plugins.
@@ -122,36 +85,17 @@ class Robut::Connection
122
85
  self.roster = Jabber::Roster::Helper.new(client)
123
86
  roster.wait_for_roster
124
87
 
125
- # Add the callback from messages that occur inside the room
126
- muc.on_message do |time, nick, message|
127
- plugins = Robut::Plugin.plugins.map { |p| p.new(self, nil) }
128
- handle_message(plugins, time, nick, message)
129
- end
130
-
131
- # Add the callback from direct messages. Turns out the
132
- # on_private_message callback doesn't do what it sounds like, so I
133
- # have to go a little deeper into xmpp4r to get this working.
134
- client.add_message_callback(200, self) do |message|
135
- if !muc.from_room?(message.from) && message.type == :chat && message.body
136
- time = Time.now # TODO: get real timestamp? Doesn't seem like
137
- # jabber gives it to us
138
- sender_jid = message.from
139
- plugins = Robut::Plugin.plugins.map { |p| p.new(self, sender_jid) }
140
- handle_message(plugins, time, self.roster[sender_jid].iname, message.body)
141
- true
142
- else
143
- false
144
- end
88
+ rooms = self.config.rooms.collect do |room_name|
89
+ Robut::Room.new(self, room_name).tap {|r| r.join }
145
90
  end
146
91
 
147
- muc.join(config.room + '/' + config.nick)
92
+ personal_message = Robut::PM.new(self, rooms)
148
93
 
149
94
  trap_signals
150
95
  loop { sleep 1 }
151
96
  end
152
97
 
153
- private
154
-
98
+ private
155
99
  # Since we're entering an infinite loop, we have to trap TERM and
156
100
  # INT. If something like the Rdio plugin has started a server that
157
101
  # has already trapped those signals, we want to run those signal
@@ -167,10 +111,4 @@ class Robut::Connection
167
111
  old_signal_callbacks[sig] = trap(sig) { signal_callback.call(sig) }
168
112
  end
169
113
  end
170
-
171
- # Find a jid in the roster with the given name, case-insensitively
172
- def find_jid_by_name(name)
173
- name = name.downcase
174
- roster.items.detect {|jid, item| item.iname.downcase == name}.first
175
- end
176
- end
114
+ end
@@ -3,6 +3,45 @@
3
3
  # module.
4
4
  module Robut::Plugin
5
5
 
6
+ # Contains methods that will be added directly to a class including
7
+ # Robut::Plugin.
8
+ module ClassMethods
9
+
10
+ # Sets up a 'matcher' that will try to match input being sent to
11
+ # this plugin with a regular expression. If the expression
12
+ # matches, +action+ will be performed. +action+ will be passed any
13
+ # captured groups in the regular expression as parameters. For
14
+ # example:
15
+ #
16
+ # match /^say hello to (\w+)/ do |name| ...
17
+ #
18
+ # The action is run in the context of an instance of a class that
19
+ # includes Robut::Plugin. Like +handle+, an action explicitly
20
+ # returning +true+ will stop the plugin chain from matching any
21
+ # further.
22
+ #
23
+ # Supported options:
24
+ # :sent_to_me - only try to match this regexp if it contains an @reply to robut.
25
+ # This will also strip the @reply from the message we're trying
26
+ # to match on, so ^ and $ will still do the right thing.
27
+ def match(regexp, options = {}, &action)
28
+ matchers << [regexp, options, action, @last_description]
29
+ @last_description = nil
30
+ end
31
+
32
+ # Provides a description for the next matcher
33
+ def desc(string)
34
+ @last_description = string
35
+ end
36
+
37
+ # A list of regular expressions to apply to input being sent to
38
+ # the plugin, along with blocks of actions to perform. Each
39
+ # element is a [regexp, options, action, description] array.
40
+ def matchers
41
+ @matchers ||= []
42
+ end
43
+ end
44
+
6
45
  class << self
7
46
  # A list of all available plugin classes. When you require a new
8
47
  # plugin class, you should add it to this list if you want it to
@@ -21,10 +60,18 @@ module Robut::Plugin
21
60
  # room.
22
61
  attr_accessor :private_sender
23
62
 
24
- # Creates a new instance of this plugin that references the
25
- # specified connection.
26
- def initialize(connection, private_sender = nil)
27
- self.connection = connection
63
+ attr_accessor :reply_to
64
+
65
+ # :nodoc:
66
+ def self.included(klass)
67
+ klass.send(:extend, ClassMethods)
68
+ end
69
+
70
+ # Creates a new instance of this plugin to reply to a particular
71
+ # object over that object's connection
72
+ def initialize(reply_to, private_sender = nil)
73
+ self.reply_to = reply_to
74
+ self.connection = reply_to.connection
28
75
  self.private_sender = private_sender
29
76
  end
30
77
 
@@ -33,9 +80,9 @@ module Robut::Plugin
33
80
  # original message was sent. Otherwise, PMs the message to +to+.
34
81
  def reply(message, to = nil)
35
82
  if to == :room
36
- connection.reply(message, nil)
83
+ reply_to.reply(message, nil)
37
84
  else
38
- connection.reply(message, to || private_sender)
85
+ reply_to.reply(message, to || private_sender)
39
86
  end
40
87
  end
41
88
 
@@ -44,7 +91,7 @@ module Robut::Plugin
44
91
  # stripped out. This is useful to separate the 'parameters' from the
45
92
  # 'commands' in a message.
46
93
  def words(message, command = nil)
47
- reply = at_nick
94
+ reply = at_nick.downcase
48
95
  command = command.downcase if command
49
96
  message.split.reject {|word| word.downcase == reply || word.downcase == command }
50
97
  end
@@ -53,7 +100,7 @@ module Robut::Plugin
53
100
  # Given "@robut do this thing", Returns "do this thing"
54
101
  def without_nick(message)
55
102
  possible_nick, command = message.split(' ', 2)
56
- if possible_nick == at_nick
103
+ if possible_nick.casecmp(at_nick) == 0
57
104
  command
58
105
  else
59
106
  message
@@ -62,12 +109,12 @@ module Robut::Plugin
62
109
 
63
110
  # The bot's nickname, for @-replies.
64
111
  def nick
65
- connection.config.nick.split.first
112
+ connection.config.mention_name || connection.config.nick.gsub(/[^0-9a-z]/i, '')
66
113
  end
67
114
 
68
115
  # #nick with the @-symbol prepended
69
116
  def at_nick
70
- "@#{nick.downcase}"
117
+ "@#{nick}"
71
118
  end
72
119
 
73
120
  # Was +message+ sent to Robut as an @reply?
@@ -80,22 +127,56 @@ module Robut::Plugin
80
127
  # method. Plugins are handled in the order that they appear in
81
128
  # Robut::Plugin.plugins
82
129
  def handle(time, sender_nick, message)
83
- raise NotImplementedError, "Implement me in #{self.class.name}!"
130
+ if matchers.empty?
131
+ raise NotImplementedError, "Implement me in #{self.class.name}!"
132
+ else
133
+ find_match(message)
134
+ end
84
135
  end
85
136
 
86
137
  # Returns a list of messages describing the commands this plugin
87
138
  # handles.
88
139
  def usage
140
+ matchers.map do |regexp, options, action, description|
141
+ next unless description
142
+ if options[:sent_to_me]
143
+ at_nick + " " + description
144
+ else
145
+ description
146
+ end
147
+ end.compact
89
148
  end
90
149
 
91
150
  def fake_message(time, sender_nick, msg)
92
151
  # TODO: ensure this connection is threadsafe
93
- plugins = Robut::Plugin.plugins.map { |p| p.new(connection, private_sender) }
94
- connection.handle_message(plugins, time, sender_nick, msg)
152
+ plugins = Robut::Plugin.plugins.map { |p| p.new(reply_to, private_sender) }
153
+ reply_to.handle_message(plugins, time, sender_nick, msg)
95
154
  end
96
155
 
97
156
  # Accessor for the store instance
98
157
  def store
99
158
  connection.store
100
159
  end
160
+
161
+ private
162
+
163
+ # Find and run all the actions associated with matchers that match
164
+ # +message+.
165
+ def find_match(message)
166
+ matchers.each do |regexp, options, action, description|
167
+ if options[:sent_to_me] && !sent_to_me?(message)
168
+ next
169
+ end
170
+
171
+ if match_data = without_nick(message).match(regexp)
172
+ # Return true explicitly if this matcher explicitly returned true
173
+ break true if instance_exec(*match_data[1..-1], &action) == true
174
+ end
175
+ end
176
+ end
177
+
178
+ # The matchers defined by this plugin's class
179
+ def matchers
180
+ self.class.matchers
181
+ end
101
182
  end