slack-bot-server 0.3.0 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 8b273bf7f49d2acf1743f6ef74ac8e4ca3fc336d
4
- data.tar.gz: a4b089d850c7a0cceef7fe9d1643ce4843831b5d
3
+ metadata.gz: 143cba07b6d366616e823e2f9a502d1d24a4af8d
4
+ data.tar.gz: b739efffeddf7b60a7e23dc797073f3629285b8f
5
5
  SHA512:
6
- metadata.gz: 324c943e0ee722c366a08f5fb14e168b3f4145baf951c127bb9f82a5797236410552edbb6bb342da66b2abb41d79692066cab53ae86c4db5cb1335bbc1cfc06f
7
- data.tar.gz: 1bfb87e6b026251971e0dd9a35b2601c37845c075910df809112a0c443bdeeafcb4be4cb4c9d5bc0f83daaeafe9defbeef8f6e89e9eea088d3a88396b1be5b36
6
+ metadata.gz: dfdf002fd3b658f13e6feb5486586e3937c409bc6fad2e45de4b871b7dbf7d0806ed5ac6383b4afb1d88b374bfda9023a0694718aebb6f4941edd375bd777829
7
+ data.tar.gz: 5bc62c5f100ecc33f77ad809f8ca5952be5e9373ff710c834865f84048bcc2dd72663a9ffe4b0529a7a06e6235c36887b36013542d6d9829d96fc9b73cb78c9d
data/.gitignore CHANGED
@@ -7,3 +7,4 @@
7
7
  /pkg/
8
8
  /spec/reports/
9
9
  /tmp/
10
+ test_server
data/CHANGELOG.md CHANGED
@@ -1,7 +1,26 @@
1
- ## 0.3.0
1
+ ## 0.4.0
2
+
3
+ ### Added
4
+ - Allow bots to send a 'typing' message
5
+ - Messages will be sent via the Real-Team API if possible (not all message parameters are acceptable there)
6
+ - Subsequent bot callbacks won't fire if an earlier one returns `false`
7
+ - `SlackBotServer::Bot` now exposes `bot_user_name`, `bot_user_id`, `team_name`, and `team_id` methods
8
+ - The logger can now be set via `SlackBotServer.logger=`
9
+ - Access the underlying Slack client via the `SlackBotServer::Bot#client` method
10
+
11
+ ### Changes
12
+ - Swapped internal API library from `slack-api` to `slack-ruby-client`
13
+ - Improve internal bot logging API
14
+ - Ensure rtm data is reloaded when reconnecting
15
+ - Add missing/implicit requires to server.rb and bot.rb
16
+ - Only listen for instructions on the queue if its non-nil
17
+ - Fix bug where malformed bot key could crash when processing instructions
18
+ - Allow `SlackBotServer::RedisQueue.new` to take a custom redis key; note that this has changed the argument format of the initialiser
2
19
 
3
- Changes:
4
20
 
5
- - The `SlackBotServer::Server#on_new_proc` has been renamed to `Server#on_add`
6
- - The `add` and `add_bot` methods on `SlackBotServer::Server` and `SlackBotServer::RemoteControl` control have been merged as `add_bot`
7
- - Multiple arguments may be passed via the `add_bot` method to the block given to `SlackBotServer::on_add`
21
+ ## 0.3.0
22
+
23
+ ### Changes
24
+ - The `SlackBotServer::Server#on_new_proc` has been renamed to `Server#on_add`
25
+ - The `add` and `add_bot` methods on `SlackBotServer::Server` and `SlackBotServer::RemoteControl` control have been merged as `add_bot`
26
+ - Multiple arguments may be passed via the `add_bot` method to the block given to `SlackBotServer::on_add`
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # SlackBotServer
2
2
 
3
- [![Build Status](https://travis-ci.org/exciting-io/slack-bot-server.svg)](https://travis-ci.org/exciting-io/slack-bot-server)
3
+ [![Build Status](https://travis-ci.org/exciting-io/slack-bot-server.svg)](https://travis-ci.org/exciting-io/slack-bot-server) [![Documentation](http://img.shields.io/badge/yard-docs-blue.svg)](http://www.rubydoc.info/github/exciting-io/slack-bot-server)
4
4
 
5
5
  If you're building an integration just for yourself, running a single bot isn't too hard and there are plenty of examples available. However, if you're building an integration for your *product* to connect with multiple teams, running multiple instances of that bot is a bit trickier.
6
6
 
@@ -55,34 +55,7 @@ server.start
55
55
 
56
56
  Running this script will start a server and keep it running; you may wish to use a tool like [Foreman](http://ddollar.github.io/foreman/) to actually start it and manage it in production.
57
57
 
58
- ### Writing a bot
59
-
60
- The provided example `SimpleBot` illustrates the main ways to build a bot:
61
-
62
- ```ruby
63
- require 'slack_bot_server/bot'
64
-
65
- class SlackBotServer::SimpleBot < SlackBotServer::Bot
66
- # Set the username displayed in Slack
67
- username 'SimpleBot'
68
-
69
- # Respond to mentions in the connected chat room (defaults to #general).
70
- # As well as the normal data provided by Slack's API, we add the `message`,
71
- # which is the `text` parameter with the username stripped out. For example,
72
- # When a user sends 'simple_bot: how are you?', the `message` data contains
73
- # only 'how are you'.
74
- on_mention do |data|
75
- reply text: "You said '#{data['message']}', and I'm frankly fascinated."
76
- end
77
-
78
- # Respond to messages sent via IM communication directly with the bot.
79
- on_im do
80
- reply text: "Hmm, OK, let me get back to you about that."
81
- end
82
- end
83
- ```
84
-
85
- ### Advanced example
58
+ ### Advanced server example
86
59
 
87
60
  This is a more advanced example of a server script, based on the that used by [Harmonia](https://harmonia.io), the product from which this was extracted.
88
61
 
@@ -96,7 +69,7 @@ require 'harmonia/slack_bot'
96
69
  # Use a Redis-based queue to add/remove bots and to trigger
97
70
  # bot messages to be sent. In this case we connect to the same
98
71
  # redis instance as Resque, just for convenience.
99
- queue = SlackBotServer::RedisQueue.new(Resque.redis)
72
+ queue = SlackBotServer::RedisQueue.new(redis: Resque.redis)
100
73
 
101
74
  server = SlackBotServer::Server.new(queue: queue)
102
75
 
@@ -130,16 +103,65 @@ end
130
103
  server.start
131
104
  ```
132
105
 
106
+ ### Writing a bot
107
+
108
+ The provided example `SimpleBot` illustrates the main ways to build a bot:
109
+
110
+ ```ruby
111
+ require 'slack_bot_server/bot'
112
+
113
+ class SlackBotServer::SimpleBot < SlackBotServer::Bot
114
+ # Set the friendly username displayed in Slack
115
+ username 'SimpleBot'
116
+ # Set the image to use as an avatar icon in Slack
117
+ icon_url 'http://my.server.example.com/assets/icon.png'
118
+
119
+ # Respond to mentions in the connected chat room (defaults to #general).
120
+ # As well as the normal data provided by Slack's API, we add the `message`,
121
+ # which is the `text` parameter with the username stripped out. For example,
122
+ # When a user sends 'simple_bot: how are you?', the `message` data contains
123
+ # only 'how are you'.
124
+ on_mention do |data|
125
+ if data['message'] == 'who are you'
126
+ reply text: "I am #{user} (user id: #{user_id}, connected to team #{team} with team id #{team_id}"
127
+ else
128
+ reply text: "You said '#{data['message']}', and I'm frankly fascinated."
129
+ end
130
+ end
131
+
132
+ # Respond to messages sent via IM communication directly with the bot.
133
+ on_im do
134
+ reply text: "Hmm, OK, let me get back to you about that."
135
+ end
136
+ end
137
+ ```
138
+
139
+ As well as the special `on_mention` and `on_im` blocks, there are a number
140
+ of other hooks that you can use when writing a bot:
141
+
142
+ * `on :message` -- will fire for every message that's received from Slack in
143
+ the rooms that this bot is a member of
144
+ * `on :start` -- will fire when the bot establishes a connection to Slack
145
+ (note that periodic disconnections will occur, so this hook is best used
146
+ to gather data about the current state of Slack. You should not assume
147
+ this is the first time the bot has ever connected)
148
+ * `on :finish` -- will fire when the bot is disconnected from Slack. This
149
+ may be because a disconnection happened, or might be because the bot was
150
+ removed from the server via the `remove_bot` command. You can check if
151
+ the bot was accidentally/intermittently disconnected via the `running?`
152
+ method, which will return true unless the bot was explicitly stopped.
153
+
154
+
133
155
  ### Managing bots
134
156
 
135
- When someone in your application wspants to connect their account with Slack, they'll need to provide a bot API token, which your application should store.
157
+ When someone in your application wants to connect their account with Slack, they'll need to provide a bot API token, which your application should store.
136
158
 
137
159
  In order to actually create and connect their bot, you can use the remote
138
160
  control to add the token to the server.
139
161
 
140
162
  ```ruby
141
163
  # Somewhere within your application
142
- queue = SlackBotServer::RedisQueue.new(Redis.new)
164
+ queue = SlackBotServer::RedisQueue.new(redis: Redis.new)
143
165
  slack_remote = SlackBotServer::RemoteControl.new(queue: queue)
144
166
  slack_remote.add_bot('user-accounts-slack-api-token')
145
167
  ```
@@ -172,7 +194,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
172
194
 
173
195
  ## Contributing
174
196
 
175
- Bug reports and pull requests are welcome on GitHub at https://github.com/exciting-io/slack_bot_server.
197
+ Bug reports and pull requests are welcome on GitHub at https://github.com/exciting-io/slack-bot-server.
176
198
 
177
199
 
178
200
  ## License
@@ -2,10 +2,21 @@ require 'slack_bot_server/version'
2
2
  require 'slack_bot_server/server'
3
3
  require 'logger'
4
4
 
5
+ # A framework for running and controlling multiple bots. This
6
+ # is designed to make it easier for developers to provide Slack
7
+ # integration for their applications, instead of having individual
8
+ # users run their own bot instances.
5
9
  module SlackBotServer
10
+ # A Logger instance, defaulting to +INFO+ level
6
11
  def self.logger
7
12
  @logger ||= Logger.new(STDOUT)
8
13
  end
14
+
15
+ # Assign the logger to be used by SlackBotServer
16
+ # @param logger [Logger]
17
+ def self.logger=(logger)
18
+ @logger = logger
19
+ end
9
20
  end
10
21
 
11
22
  SlackBotServer.logger.level = Logger::INFO
@@ -1,87 +1,215 @@
1
1
  require 'slack'
2
- require 'slack/client'
3
-
2
+ require 'slack-ruby-client'
3
+
4
+ # A superclass for integration bot implementations.
5
+ #
6
+ # A simple example:
7
+ #
8
+ # class MyBot < SlackBotServer::Bot
9
+ # # Set the friendly username displayed in Slack
10
+ # username 'My Bot'
11
+ # # Set the image to use as an avatar icon in Slack
12
+ # icon_url 'http://my.server.example.com/assets/icon.png'
13
+ #
14
+ # # Respond to mentions in the connected chat room (defaults to #general).
15
+ # # As well as the normal data provided by Slack's API, we add the `message`,
16
+ # # which is the `text` parameter with the username stripped out. For example,
17
+ # # When a user sends 'simple_bot: how are you?', the `message` data contains
18
+ # # only 'how are you'.
19
+ # on_mention do |data|
20
+ # if data['message'] == 'who are you'
21
+ # reply text: "I am #{user} (user id: #{user_id}, connected to team #{team} with team id #{team_id}"
22
+ # else
23
+ # reply text: "You said '#{data['message']}', and I'm frankly fascinated."
24
+ # end
25
+ # end
26
+ #
27
+ # # Respond to messages sent via IM communication directly with the bot.
28
+ # on_im do
29
+ # reply text: "Hmm, OK, let me get back to you about that."
30
+ # end
31
+ # end
32
+ #
4
33
  class SlackBotServer::Bot
34
+
35
+ # The user ID of the special slack user +SlackBot+
5
36
  SLACKBOT_USER_ID = 'USLACKBOT'
6
37
 
7
- attr_reader :key
38
+ attr_reader :key, :token, :client
8
39
 
40
+ # Raised if Slack rejected the token during authentication.
9
41
  class InvalidToken < RuntimeError; end
10
42
 
43
+ # Create a new bot.
44
+ # This is normally called from within the block passed to
45
+ # {SlackBotServer::Server#on_add}, which should return a new
46
+ # bot instance.
47
+ # @param token [String] the Slack bot token to use for authentication
48
+ # @param key [String] a key used to target messages to this bot from
49
+ # your application when using {RemoteControl}. If not provided,
50
+ # this defaults to the token.
11
51
  def initialize(token:, key: nil)
12
52
  @token = token
13
53
  @key = key || @token
14
- @api = ::Slack::Client.new(token: @token)
15
- @im_channel_ids = []
16
- @channel_ids = []
54
+ @client = ::Slack::RealTime::Client.new(token: @token)
55
+ @im_channels = []
56
+ @channels = []
17
57
  @connected = false
18
58
  @running = false
19
59
 
20
- raise InvalidToken unless rtm_start_data['ok']
60
+ raise InvalidToken unless @client.web_client.auth_test['ok']
61
+ end
62
+
63
+ # Returns the username (for @ replying) of the bot user we are connected as,
64
+ # e.g. +'simple_bot'+
65
+ def bot_user_name
66
+ @client.self['name']
67
+ end
68
+
69
+ # Returns the ID of the bot user we are connected as, e.g. +'U123456'+
70
+ def bot_user_id
71
+ @client.self['id']
72
+ end
73
+
74
+ # Returns the name of the team we are connected to, e.g. +'My Team'+
75
+ def team_name
76
+ @client.team['name']
77
+ end
78
+
79
+ # Returns the ID of the team we are connected to, e.g. +'T234567'+
80
+ def team_id
81
+ @client.team['id']
21
82
  end
22
83
 
84
+ # Send a message to Slack
85
+ # @param options [Hash] a hash containing any of the following:
86
+ # channel:: the name ('#general'), or the ID of the channel to send to
87
+ # text:: the actual text of the message
88
+ # username:: the name the message should appear from; defaults to the
89
+ # value given to `username` in the Bot class definition
90
+ # icon_url:: the image url to use as the avatar for this message;
91
+ # defaults to the value given to `icon_url` in the Bot
92
+ # class definition
23
93
  def say(options)
24
- @api.chat_postMessage(default_message_options.merge(options))
94
+ message = symbolize_keys(default_message_options.merge(options))
95
+
96
+ if rtm_incompatible_message?(message)
97
+ debug "Sending via Web API", message
98
+ @client.web_client.chat_postMessage(message)
99
+ else
100
+ debug "Sending via RTM API", message
101
+ @client.message(message)
102
+ end
25
103
  end
26
104
 
105
+ # Sends a message to every channel this bot is a member of
106
+ # @param options [Hash] As {#say}, although the +:channel+ option is
107
+ # redundant
27
108
  def broadcast(options)
28
- @channel_ids.each do |channel|
29
- say(options.merge(channel: channel))
109
+ @channels.each do |channel|
110
+ say(options.merge(channel: channel['id']))
30
111
  end
31
112
  end
32
113
 
114
+ # Sends a reply to the same channel as the last message that was
115
+ # received by this bot.
116
+ # @param options [Hash] As {#say}, although the +:channel+ option is
117
+ # redundant
33
118
  def reply(options)
34
119
  channel = @last_received_data['channel']
35
120
  say(options.merge(channel: channel))
36
121
  end
37
122
 
123
+ # Sends a message via IM to a user
124
+ # @param user_id [String] the Slack user ID of the person to receive this message
125
+ # @param options [Hash] As {#say}, although the +:channel+ option is
126
+ # redundant
38
127
  def say_to(user_id, options)
39
- result = @api.im_open(user: user_id)
128
+ result = @client.web_client.im_open(user: user_id)
40
129
  channel = result['channel']['id']
41
130
  say(options.merge(channel: channel))
42
131
  end
43
132
 
133
+ # Sends a typing notification
134
+ # @param options [Hash] can contain +:channel+, which should be an ID; if no options
135
+ # are provided, the channel from the most recently recieved message is used
136
+ def typing(options={})
137
+ last_received_channel = @last_received_data ? @last_received_data['channel'] : nil
138
+ default_options = {channel: last_received_channel}
139
+ @client.typing(default_options.merge(options))
140
+ end
141
+
142
+ # Call a method directly on the Slack web API (via Slack::Web::Client).
143
+ # Useful for debugging only.
44
144
  def call(method, args)
45
145
  args.symbolize_keys!
46
- @api.send(method, args)
146
+ @client.web_client.send(method, args)
47
147
  end
48
148
 
149
+ # Starts the bot running.
150
+ # You should not call this method; instead, the server will call it
151
+ # when it is ready for the bot to connect
152
+ # @see Server#start
49
153
  def start
50
154
  @running = true
51
- @ws = Faye::WebSocket::Client.new(websocket_url, nil, ping: 60)
52
155
 
53
- @ws.on :open do |event|
156
+ @client.on :open do |event|
54
157
  @connected = true
55
158
  log "connected to '#{team_name}'"
56
159
  run_callbacks(:start)
57
160
  end
58
161
 
59
- @ws.on :message do |event|
162
+ @client.on :message do |data|
60
163
  begin
61
- debug event.data
62
- handle_message(event)
164
+ debug message: data
165
+ handle_message(data)
63
166
  rescue => e
64
167
  log error: e
65
168
  log backtrace: e.backtrace
66
169
  end
67
170
  end
68
171
 
69
- @ws.on :close do |event|
172
+ @client.on :im_created do |data|
173
+ log "Adding new IM channel", data['channel']
174
+ @im_channels << data['channel']
175
+ end
176
+
177
+ @client.on :channel_joined do |data|
178
+ log "Adding new channel", data['channel']
179
+ @channels << data['channel']
180
+ end
181
+
182
+ @client.on :channel_left do |data|
183
+ channel_id = data['channel']
184
+ log "Removing channel: #{channel_id}"
185
+ @channels.delete_if { |c| c['id'] == channel_id }
186
+ end
187
+
188
+ @client.on :close do |event|
70
189
  log "disconnected"
71
190
  @connected = false
72
- if @running
73
- start
74
- end
191
+ run_callbacks(:finish)
75
192
  end
193
+
194
+ @client.start_async
76
195
  end
77
196
 
197
+ # Stops the bot from running. You should not call this method; instead
198
+ # send the server a +remote_bot+ message
199
+ # @see Server#remove_bot
78
200
  def stop
79
201
  log "closing connection"
80
202
  @running = false
81
- @ws.close
203
+ @client.stop!
82
204
  log "closed"
83
205
  end
84
206
 
207
+ # Returns +true+ if this bot is (or should be) running
208
+ def running?
209
+ @running
210
+ end
211
+
212
+ # Returns +true+ if this bot is currently connected to Slack
85
213
  def connected?
86
214
  @connected
87
215
  end
@@ -89,44 +217,117 @@ class SlackBotServer::Bot
89
217
  class << self
90
218
  attr_reader :mention_keywords
91
219
 
220
+ # Sets the username this bot should use
221
+ #
222
+ # class MyBot < SlackBotServer::Bot
223
+ # username 'My Bot'
224
+ #
225
+ # # etc
226
+ # end
227
+ #
228
+ # will result in the friendly name 'My Bot' appearing beside
229
+ # the messages in your Slack rooms
92
230
  def username(name)
93
231
  default_message_options[:username] = name
94
232
  end
95
233
 
234
+ # Sets the image to use as an avatar for this bot
235
+ #
236
+ # class MyBot < SlackBotServer::Bot
237
+ # icon_url 'http://example.com/bot.png'
238
+ #
239
+ # # etc
240
+ # end
96
241
  def icon_url(url)
97
242
  default_message_options[:icon_url] = url
98
243
  end
99
244
 
245
+ # Sets the keywords in messages that will trigger the
246
+ # +on_mention+ callback
247
+ #
248
+ # class MyBot < SlackBotServer::Bot
249
+ # mention_as 'hey', 'bot'
250
+ #
251
+ # # etc
252
+ # end
253
+ #
254
+ # will mean the +on_mention+ callback fires for messages
255
+ # like "hey you!" and "bot, what are you thinking".
256
+ #
257
+ # Mention keywords are only matched at the start of messages,
258
+ # so the text "I love you, bot" won't trigger this callback.
259
+ # To implement general keyword spotting, use a custom
260
+ # +on :message+ callback.
261
+ #
262
+ # If this is not called, the default mention keyword is the
263
+ # bot username, e.g. +simple_bot+
100
264
  def mention_as(*keywords)
101
265
  @mention_keywords = keywords
102
266
  end
103
267
 
268
+ # Holds default options to send with each message to Slack
104
269
  def default_message_options
105
- @default_message_options ||= {}
270
+ @default_message_options ||= {type: 'message'}
106
271
  end
107
272
 
273
+ # All callbacks defined on this class
108
274
  def callbacks
109
275
  @callbacks ||= {}
110
276
  end
111
277
 
278
+ # Returns all callbacks (including those in superclasses) for a given
279
+ # event type
112
280
  def callbacks_for(type)
113
- matching_callbacks = callbacks[type.to_sym] || []
114
281
  if superclass.respond_to?(:callbacks_for)
115
- matching_callbacks += superclass.callbacks_for(type)
282
+ matching_callbacks = superclass.callbacks_for(type)
283
+ else
284
+ matching_callbacks = []
116
285
  end
117
- matching_callbacks.reverse
286
+ matching_callbacks += callbacks[type.to_sym] if callbacks[type.to_sym]
287
+ matching_callbacks
118
288
  end
119
289
 
290
+ # Register a callback
291
+ #
292
+ # class MyBot < SlackBotServer::Bot
293
+ # on :message do
294
+ # reply text: 'I heard a message, so now I am responding!'
295
+ # end
296
+ # end
297
+ #
298
+ # Possible callbacks are:
299
+ # +:start+ :: fires when the bot establishes a connection to Slack
300
+ # +:finish+ :: fires when the bot is disconnected from Slack
301
+ # +:message+ :: fires when any message is sent in any channel the bot is
302
+ # connected to
303
+ #
304
+ # Multiple blocks for each type can be registered; they will be run
305
+ # in the order they are defined.
306
+ #
307
+ # If any block returns +false+, later blocks will not be fired.
120
308
  def on(type, &block)
121
309
  callbacks[type.to_sym] ||= []
122
310
  callbacks[type.to_sym] << block
123
311
  end
124
312
 
313
+ # Define a callback to run when any of the mention keywords are
314
+ # present in a message.
315
+ #
316
+ # Typically this will be for messages in open channels, where as
317
+ # user directs a message to this bot, e.g. "@simple_bot hello"
318
+ #
319
+ # By default, the mention keyword is simply the bot's username
320
+ # e.g. +simple_bot+
321
+ #
322
+ # As well as the raw Slack data about the message, the data +Hash+
323
+ # yielded to the given block will contain a +'message'+ key,
324
+ # which holds the text sent with the keyword removed.
125
325
  def on_mention(&block)
126
326
  on(:message) do |data|
327
+ debug on_message: data, bot_message: bot_message?(data)
127
328
  if !bot_message?(data) &&
128
329
  (data['text'] =~ /\A(#{mention_keywords.join('|')})[\s\:](.*)/i ||
129
- data['text'] =~ /\A(<@#{user_id}>)[\s\:](.*)/)
330
+ data['text'] =~ /\A(<@#{bot_user_id}>)[\s\:](.*)/)
130
331
  message = $2.strip
131
332
  @last_received_data = data.merge('message' => message)
132
333
  instance_exec(@last_received_data, &block)
@@ -134,8 +335,11 @@ class SlackBotServer::Bot
134
335
  end
135
336
  end
136
337
 
338
+ # Define a callback to run when any a user sends a direct message
339
+ # to this bot
137
340
  def on_im(&block)
138
341
  on(:message) do |data|
342
+ debug on_im: data, bot_message: bot_message?(data), is_im_channel: is_im_channel?(data['channel'])
139
343
  if is_im_channel?(data['channel']) && !bot_message?(data)
140
344
  @last_received_data = data.merge('message' => data['text'])
141
345
  instance_exec(@last_received_data, &block)
@@ -148,100 +352,83 @@ class SlackBotServer::Bot
148
352
  load_channels
149
353
  end
150
354
 
151
- on :im_created do |data|
152
- channel_id = data['channel']['id']
153
- log "Adding new IM channel: #{channel_id}"
154
- @im_channel_ids << channel_id
155
- end
156
-
157
- on :channel_joined do |data|
158
- channel_id = data['channel']['id']
159
- log "Adding new channel: #{channel_id}"
160
- @channel_ids << channel_id
161
- end
162
-
163
- on :channel_left do |data|
164
- channel_id = data['channel']
165
- log "Removing channel: #{channel_id}"
166
- @channel_ids.delete(channel_id)
355
+ on :finish do
356
+ if @running
357
+ start
358
+ end
167
359
  end
168
360
 
361
+ # Returns a String representation of this {Bot}
362
+ # @return String
169
363
  def to_s
170
364
  "<#{self.class.name} key:#{key}>"
171
365
  end
172
366
 
173
367
  private
174
368
 
175
- def handle_message(event)
176
- data = MultiJson.load(event.data)
177
- run_callbacks(data["type"], data) if data["type"]
369
+ def handle_message(data)
370
+ run_callbacks(data['type'], data)
178
371
  end
179
372
 
180
373
  def run_callbacks(type, data=nil)
181
374
  relevant_callbacks = self.class.callbacks_for(type)
182
375
  relevant_callbacks.each do |c|
183
- instance_exec(data, &c)
376
+ response = instance_exec(data, &c)
377
+ break if response == false
184
378
  end
185
379
  end
186
380
 
187
- def log(message)
188
- text = message.is_a?(String) ? message : message.inspect
189
- text = "[BOT/#{user}] #{text}"
190
- SlackBotServer.logger.info(message)
191
- end
192
-
193
- def debug(message)
194
- text = message.is_a?(String) ? message : message.inspect
195
- text = "[BOT/#{user}] #{text}"
196
- SlackBotServer.logger.debug(message)
197
- end
198
-
199
- def user
200
- rtm_start_data['self']['name']
381
+ def log(*args)
382
+ SlackBotServer.logger.info(log_string(*args))
201
383
  end
202
384
 
203
- def user_id
204
- rtm_start_data['self']['id']
385
+ def debug(*args)
386
+ SlackBotServer.logger.debug(log_string(*args))
205
387
  end
206
388
 
207
- def team_name
208
- rtm_start_data['team']['name']
389
+ def log_string(*args)
390
+ text = if args.length == 1 && args.first.is_a?(String)
391
+ args.first
392
+ else
393
+ args.map { |a| a.is_a?(String) ? a : a.inspect }.join(", ")
394
+ end
395
+ "[BOT/#{bot_user_name}] #{text}"
209
396
  end
210
397
 
211
398
  def load_channels
212
399
  log "Loading channels"
213
- @im_channel_ids = rtm_start_data['ims'].map { |d| d['id'] }
214
- log im_channels: @im_channel_ids
215
- @channel_ids = rtm_start_data['channels'].select { |d| d['is_member'] == true }.map { |d| d['id'] }
216
- log channels: @channel_ids
400
+ @im_channels = @client.ims
401
+ log im_channels: @im_channels
402
+ @channels = @client.channels.select { |d| d['is_member'] == true }
403
+ log channels: @channels
217
404
  end
218
405
 
219
- def websocket_url
220
- rtm_start_data['url']
221
- end
222
-
223
- def rtm_start_data
224
- @rtm_start_data ||= @api.post('rtm.start')
406
+ def channel(id)
407
+ (@channels + @im_channels).find { |c| c['id'] == id }
225
408
  end
226
409
 
227
410
  def is_im_channel?(id)
228
- @im_channel_ids.include?(id)
411
+ channel(id)['is_im'] == true
229
412
  end
230
413
 
231
414
  def bot_message?(data)
232
415
  data['subtype'] == 'bot_message' ||
233
416
  data['user'] == SLACKBOT_USER_ID ||
234
- data['user'] == user_id ||
417
+ data['user'] == bot_user_id ||
235
418
  change_to_previous_bot_message?(data)
236
419
  end
237
420
 
238
421
  def change_to_previous_bot_message?(data)
239
422
  data['subtype'] == 'message_changed' &&
240
- data['previous_message']['user'] == user_id
423
+ data['previous_message']['user'] == bot_user_id
241
424
  end
242
425
 
243
- def websocket_url
244
- @api.post('rtm.start')['url']
426
+ def rtm_incompatible_message?(data)
427
+ data[:attachments].nil? ||
428
+ data[:username].nil? ||
429
+ data[:icon_url].nil? ||
430
+ data[:icon_emoji].nil? ||
431
+ data[:channel].match(/^#/).nil?
245
432
  end
246
433
 
247
434
  def default_message_options
@@ -249,6 +436,13 @@ class SlackBotServer::Bot
249
436
  end
250
437
 
251
438
  def mention_keywords
252
- self.class.mention_keywords || [user]
439
+ self.class.mention_keywords || [bot_user_name]
440
+ end
441
+
442
+ def symbolize_keys(hash)
443
+ hash.keys.each do |key|
444
+ hash[key.to_sym] = hash.delete(key)
445
+ end
446
+ hash
253
447
  end
254
448
  end
@@ -1,12 +1,22 @@
1
+ # A local implementation of a queue.
2
+ #
3
+ # Obviously this can't be used to communicate between
4
+ # multiple processes, let alone multiple machines, but
5
+ # it serves to demonstrate the expected API.
1
6
  class SlackBotServer::LocalQueue
7
+ # Creates a new local in-memory queue
2
8
  def initialize
3
9
  @queue = Queue.new
4
10
  end
5
11
 
12
+ # Push a value onto the back of the queue
6
13
  def push(value)
7
14
  @queue << value
8
15
  end
9
16
 
17
+ # Pop a value from the front of the queue
18
+ # @return [Object, nil] returns the object from the front of the
19
+ # queue, or nil if the queue is empty
10
20
  def pop
11
21
  value = @queue.pop(true) rescue ThreadError
12
22
  value == ThreadError ? nil : value
@@ -1,8 +1,14 @@
1
1
  require 'multi_json'
2
2
 
3
+ # An implementation of the quue interface that uses
4
+ # Redis as a data conduit.
3
5
  class SlackBotServer::RedisQueue
4
- def initialize(redis=nil)
5
- @key = 'slack_bot_server:queue'
6
+ # Creates a new queue
7
+ # @param redis [Redis] an instance of the ruby +Redis+ client. If
8
+ # nil, one will be created using the default hostname and port
9
+ # @param key [String] the key to store the queue against
10
+ def initialize(redis: nil, key: 'slack_bot_server:queue')
11
+ @key = key
6
12
  @redis = if redis
7
13
  redis
8
14
  else
@@ -11,10 +17,15 @@ class SlackBotServer::RedisQueue
11
17
  end
12
18
  end
13
19
 
20
+ # Push a value onto the back of the queue.
21
+ # @param value [Object] this will be turned into JSON when stored
14
22
  def push(value)
15
23
  @redis.rpush @key, MultiJson.dump(value)
16
24
  end
17
25
 
26
+ # Pop a value from the front of the queue
27
+ # @return [Object] the object on the queue, reconstituted from its
28
+ # JSON string
18
29
  def pop
19
30
  json_value = @redis.lpop @key
20
31
  if json_value
@@ -3,32 +3,57 @@
3
3
  # This should be initialized with a queue that is shared with the
4
4
  # targetted server (e.g. the same local queue instance, or a
5
5
  # redis queue instance that points at the same redis server).
6
-
7
6
  class SlackBotServer::RemoteControl
7
+ # Create a new instance of a remote control
8
+ # @param queue [Object] any Object conforming to the queue API
9
+ # (i.e. with #push and #pop methods)
8
10
  def initialize(queue:)
9
11
  @queue = queue
10
12
  end
11
13
 
14
+ # Sends an +add_bot+ command to the {SlackBotServer::Server server}.
15
+ # See {SlackBotServer::Server#add_bot} for arguments.
12
16
  def add_bot(*args)
13
17
  @queue.push([:add_bot, *args])
14
18
  end
15
19
 
20
+ # Sends a +remove_bot+ command to the server.
21
+ # @param key [String] the key of the bot to remove.
16
22
  def remove_bot(key)
17
23
  @queue.push([:remove_bot, key])
18
24
  end
19
25
 
26
+ # Sends an +broadcast+ command to the {SlackBotServer::Server server}.
27
+ # @param key [String] the key of the bot which should send the message
28
+ # @param message_data [Hash] passed directly to
29
+ # {SlackBotServer::Bot#broadcast}; see there for argument details.
20
30
  def broadcast(key, message_data)
21
31
  @queue.push([:broadcast, key, message_data])
22
32
  end
23
33
 
34
+ # Sends an +say+ command to the {SlackBotServer::Server server}.
35
+ # @param key [String] the key of the bot which should send the message.
36
+ # @param message_data [Hash] passed directly to
37
+ # {SlackBotServer::Bot#say}; see there for argument details.
24
38
  def say(key, message_data)
25
39
  @queue.push([:say, key, message_data])
26
40
  end
27
41
 
42
+ # Sends an +say_to+ command to the {SlackBotServer::Server server}.
43
+ # @param key [String] the key of the bot which should send the message.
44
+ # @param user_id [String] the Slack user ID of the person who should
45
+ # receive the message.
46
+ # @param message_data [Hash] passed directly to
47
+ # {SlackBotServer::Bot#say_to}; see there for argument details.
28
48
  def say_to(key, user_id, message_data)
29
49
  @queue.push([:say_to, key, user_id, message_data])
30
50
  end
31
51
 
52
+ # Sends a message to be called directly on the slack web API. Generally
53
+ # for debugging only.
54
+ # @param key [String] the key of the bot which should send the message.
55
+ # @param method [String, Symbol] the name of the method to call
56
+ # @param args [Array] the arguments for the method to call
32
57
  def call(key, method, args)
33
58
  @queue.push([:call, [key, method, args]])
34
59
  end
@@ -1,10 +1,58 @@
1
1
  require 'slack_bot_server/bot'
2
2
  require 'slack_bot_server/simple_bot'
3
3
  require 'slack_bot_server/redis_queue'
4
+ require 'eventmachine'
4
5
 
6
+ # Implements a server for running multiple Slack bots. Bots can be
7
+ # dynamically added and removed, and can be interacted with from
8
+ # external services (like your application).
9
+ #
10
+ # To use this, you should create a script to run along side your
11
+ # application. A simple example:
12
+ #
13
+ # #!/usr/bin/env ruby
14
+ #
15
+ # require 'slack_bot_server'
16
+ # require 'slack_bot_server/redis_queue'
17
+ # require 'slack_bot_server/simple_bot'
18
+ #
19
+ # # Use a Redis-based queue to add/remove bots and to trigger
20
+ # # bot messages to be sent
21
+ # queue = SlackBotServer::RedisQueue.new
22
+ #
23
+ # # Create a new server using that queue
24
+ # server = SlackBotServer::Server.new(queue: queue)
25
+ #
26
+ # # How your application-specific should be created when the server
27
+ # # is told about a new slack api token to connect with
28
+ # server.on_add do |token|
29
+ # # Return a new bot instance to the server. `SimpleBot` is a provided
30
+ # # example bot with some very simple behaviour.
31
+ # SlackBotServer::SimpleBot.new(token: token)
32
+ # end
33
+ #
34
+ # # Start the server. This method blocks, and will not return until
35
+ # # the server is killed.
36
+ # server.start
37
+ #
38
+ # The key features are:
39
+ #
40
+ # * creating a queue as a conduit for commands from your app
41
+ # * creating an instance of {Server} with that queue
42
+ # * defining an #on_add block, which is run whenever you need to
43
+ # start a new bot. This block contains the custom code relevant to
44
+ # your particular service, most typically the instantiation of a bot
45
+ # class that implements the logic you want
46
+ # * calling {Server#start}, to actually run the server and start
47
+ # listening for commands from the queue and connecting bots to Slack
48
+ # itself
49
+ #
5
50
  class SlackBotServer::Server
6
51
  attr_reader :queue
7
52
 
53
+ # Creates a new {Server}
54
+ # @param queue [Object] anything that implements the queue protocol
55
+ # (e.g. #push and #pop)
8
56
  def initialize(queue: SlackBotServer::LocalQueue.new)
9
57
  @queue = queue
10
58
  @bots = {}
@@ -12,41 +60,43 @@ class SlackBotServer::Server
12
60
  @running = false
13
61
  end
14
62
 
15
- # Define the block which should be called when the `add_bot` method is
16
- # called, or the `add_bot` message is sent via a queue. This block
63
+ # Define the block which should be called when the #add_bot method is
64
+ # called, or the +add_bot+ message is sent via a queue. This block
17
65
  # should return a bot (which responds to start), in which case it will
18
66
  # be added and started. If anything else is returned, it will be ignored.
19
67
  def on_add(&block)
20
68
  @add_proc = block
21
69
  end
22
70
 
71
+ # Starts the server. This method will not return; call it at the
72
+ # end of your server script. It will start all bots it knows about
73
+ # (i.e. bots added via #add_bot before the server was started),
74
+ # and then listen for new instructions.
75
+ # @see Bot#start
23
76
  def start
24
77
  EM.run do
25
78
  @running = true
26
79
  @bots.each { |key, bot| bot.start }
27
- add_timers
28
- end
29
- end
30
-
31
- def add_timers
32
- EM.add_periodic_timer(1) do
33
- begin
34
- next_message = queue.pop
35
- process_instruction(next_message) if next_message
36
- rescue => e
37
- log_error(e)
38
- end
80
+ listen_for_instructions if queue
39
81
  end
40
82
  end
41
83
 
84
+ # Starts the server in the background, via a Thread
42
85
  def start_in_background
43
86
  Thread.start { start }
44
87
  end
45
88
 
89
+ # Find a bot added to this server. Returns nil if no bot was found
90
+ # @param key [String] the key of the bot we're looking for
91
+ # @return Bot
46
92
  def bot(key)
47
93
  @bots[key.to_sym]
48
94
  end
49
95
 
96
+ # Adds a bot to this server
97
+ # Calls the block given to {#on_add} with the arguments given. The block
98
+ # should yield a bot, typically a subclass of {Bot}.
99
+ # @see #on_add
50
100
  def add_bot(*args)
51
101
  bot = @add_proc.call(*args)
52
102
  if bot.respond_to?(:start)
@@ -58,6 +108,9 @@ class SlackBotServer::Server
58
108
  log_error(e)
59
109
  end
60
110
 
111
+ # Stops and removes a bot from the server
112
+ # @param key [String] the key of the bot to remove
113
+ # @see SlackBotServer::Bot#stop
61
114
  def remove_bot(key)
62
115
  if (bot = bot(key))
63
116
  bot.stop
@@ -69,36 +122,45 @@ class SlackBotServer::Server
69
122
 
70
123
  private
71
124
 
125
+ def listen_for_instructions
126
+ EM.add_periodic_timer(1) do
127
+ begin
128
+ next_message = queue.pop
129
+ process_instruction(next_message) if next_message
130
+ rescue => e
131
+ log_error(e)
132
+ end
133
+ end
134
+ end
135
+
72
136
  def process_instruction(instruction)
73
137
  type, *args = instruction
74
- case type.to_sym
75
- when :add_bot
76
- log "adding bot: #{args.inspect}"
77
- add_bot(*args)
78
- when :remove_bot
79
- key = args.first
80
- remove_bot(key)
81
- when :broadcast
82
- key, message_data = args
83
- log "[#{key}] broadcast: #{message_data}"
84
- bot = bot(key)
85
- bot.broadcast(message_data)
86
- when :say
87
- key, message_data = args
88
- log "[#{key}] say: #{message_data}"
89
- bot = bot(key)
90
- bot.say(message_data)
91
- when :say_to
92
- key, user_id, message_data = args
93
- log "[#{key}] say_to: (#{user_id}) #{message_data}"
94
- bot = bot(key)
95
- bot.say_to(user_id, message_data)
96
- when :call
97
- key, method, method_args = args
98
- bot = bot(key)
99
- bot.call(method, method_args)
138
+ bot_key = args.shift
139
+ if type == :add_bot
140
+ log "adding bot: #{bot_key} #{args.inspect}"
141
+ add_bot(bot_key, *args)
100
142
  else
101
- log unknown_command: instruction
143
+ with_bot(bot_key) do |bot|
144
+ case type.to_sym
145
+ when :remove_bot
146
+ remove_bot(bot_key)
147
+ when :broadcast
148
+ log "[#{bot_key}] broadcast: #{args}"
149
+ bot.broadcast(*args)
150
+ when :say
151
+ log "[#{bot_key}] say: #{args}"
152
+ bot.say(*args)
153
+ when :say_to
154
+ user_id, message_data = args
155
+ log "[#{bot_key}] say_to: (#{user_id}) #{message_data}"
156
+ bot.say_to(user_id, message_data)
157
+ when :call
158
+ method, method_args = args
159
+ bot.call(method, method_args)
160
+ else
161
+ log unknown_command: instruction
162
+ end
163
+ end
102
164
  end
103
165
  end
104
166
 
@@ -111,4 +173,12 @@ class SlackBotServer::Server
111
173
  SlackBotServer.logger.warn("Error in server: #{e} - #{e.message}")
112
174
  SlackBotServer.logger.warn(e.backtrace.join("\n"))
113
175
  end
176
+
177
+ def with_bot(key)
178
+ if bot = bot(key)
179
+ yield bot
180
+ else
181
+ log("Unknown bot: #{key}")
182
+ end
183
+ end
114
184
  end
@@ -1,5 +1,6 @@
1
1
  require 'slack_bot_server/bot'
2
2
 
3
+ # A simple demonstration of a bot
3
4
  class SlackBotServer::SimpleBot < SlackBotServer::Bot
4
5
  # Set the username displayed in Slack
5
6
  username 'SimpleBot'
@@ -10,7 +11,11 @@ class SlackBotServer::SimpleBot < SlackBotServer::Bot
10
11
  # When a user sends 'simple_bot: how are you?', the `message` data contains
11
12
  # only 'how are you'.
12
13
  on_mention do |data|
13
- reply text: "You said '#{data['message']}', and I'm frankly fascinated."
14
+ if data['message'] == 'who are you'
15
+ reply text: "I am #{user} (id: #{user_id}), connected to team #{team} (id #{team_id})"
16
+ else
17
+ reply text: "You said '#{data['message']}', and I'm frankly fascinated."
18
+ end
14
19
  end
15
20
 
16
21
  # Respond to messages sent via IM communication directly with the bot.
@@ -1,3 +1,4 @@
1
1
  module SlackBotServer
2
- VERSION = "0.3.0"
2
+ # The current version of the +SlackBotServer+ framework
3
+ VERSION = "0.4.0"
3
4
  end
@@ -19,7 +19,8 @@ Gem::Specification.new do |spec|
19
19
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
20
20
  spec.require_paths = ["lib"]
21
21
 
22
- spec.add_dependency "slack-api", "~> 1.1"
22
+ spec.add_dependency "slack-ruby-client", "~> 0.5"
23
+ spec.add_dependency "faye-websocket", "~> 0.10"
23
24
  spec.add_dependency "multi_json"
24
25
 
25
26
  spec.add_development_dependency "bundler", "~> 1.10"
@@ -27,4 +28,5 @@ Gem::Specification.new do |spec|
27
28
  spec.add_development_dependency "redis"
28
29
  spec.add_development_dependency "rspec"
29
30
  spec.add_development_dependency "rspec-eventmachine"
31
+ spec.add_development_dependency "yard"
30
32
  end
metadata CHANGED
@@ -1,29 +1,43 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: slack-bot-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Adam
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2016-01-10 00:00:00.000000000 Z
11
+ date: 2016-01-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
- name: slack-api
14
+ name: slack-ruby-client
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '1.1'
19
+ version: '0.5'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '1.1'
26
+ version: '0.5'
27
+ - !ruby/object:Gem::Dependency
28
+ name: faye-websocket
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.10'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.10'
27
41
  - !ruby/object:Gem::Dependency
28
42
  name: multi_json
29
43
  requirement: !ruby/object:Gem::Requirement
@@ -108,6 +122,20 @@ dependencies:
108
122
  - - ">="
109
123
  - !ruby/object:Gem::Version
110
124
  version: '0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: yard
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
111
139
  description: This software lets you write and host multiple slack bots, potentially
112
140
  for multiple different teams or even services.
113
141
  email:
@@ -159,3 +187,4 @@ signing_key:
159
187
  specification_version: 4
160
188
  summary: A server for hosting slack bots.
161
189
  test_files: []
190
+ has_rdoc: