-
Notifications
You must be signed in to change notification settings - Fork 637
/
client.coffee
384 lines (343 loc) · 16.6 KB
/
client.coffee
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
_ = require "lodash"
Promise = require "bluebird"
{RtmClient, WebClient} = require "@slack/client"
SlackFormatter = require "./formatter"
class SlackClient
###*
# Number used for limit when making paginated requests to Slack Web API list methods
# @private
###
@PAGE_SIZE = 100
###*
# @constructor
# @param {Object} options - Configuration options for this SlackClient instance
# @param {string} options.token - Slack API token for authentication
# @param {Object} [options.rtm={}] - Configuration options for owned RtmClient instance
# @param {Object} [options.rtmStart={}] - Configuration options for RtmClient#start() method
# @param {boolean} [options.noRawText=false] - Deprecated: All SlackTextMessages (subtype of TextMessage) will contain
# both the formatted text property and the rawText property
# @param {Robot} robot - Hubot robot instance
###
constructor: (options, @robot) ->
# Client initialization
# NOTE: the recommended initialization options are `{ dataStore: false, useRtmConnect: true }`. However the
# @rtm.dataStore property is publically accessible, so the recommended settings cannot be used without breaking
# this object's API. The property is no longer used internally.
@rtm = new RtmClient options.token, options.rtm
@web = new WebClient options.token, { maxRequestConcurrency: 1 }
@robot.logger.debug "RtmClient initialized with options: #{JSON.stringify(options.rtm)}"
@rtmStartOpts = options.rtmStart || {}
# Message formatter
# NOTE: the SlackFormatter class is deprecated. However the @format property is publicly accessible, so it cannot
# be removed without breaking this object's API. The property is no longer used internally.
@format = new SlackFormatter(@rtm.dataStore, @robot)
# Map to convert bot user IDs (BXXXXXXXX) to user representations for events from custom
# integrations and apps without a bot user
@botUserIdMap = {}
# Map to convert conversation IDs to conversation representations
@channelData = {}
# Event handling
# NOTE: add channel join and leave events
@rtm.on "message", @eventWrapper, this
@rtm.on "reaction_added", @eventWrapper, this
@rtm.on "reaction_removed", @eventWrapper, this
@rtm.on "presence_change", @eventWrapper, this
@rtm.on "member_joined_channel", @eventWrapper, this
@rtm.on "member_left_channel", @eventWrapper, this
@rtm.on "user_change", @updateUserInBrain, this
@eventHandler = undefined
###*
# Open connection to the Slack RTM API
#
# @public
###
connect: ->
@robot.logger.debug "RtmClient#start() with options: #{JSON.stringify(@rtmStartOpts)}"
@rtm.start(@rtmStartOpts)
###*
# Set event handler
#
# @public
# @param {SlackClient~eventHandler} callback
###
onEvent: (callback) ->
@eventHandler = callback if @eventHandler != callback
###*
# DEPRECATED Attach event handlers to the RTM stream
# @public
# @deprecated This method is being removed without a replacement in the next major version.
###
on: (type, callback) ->
@robot.logger.warning "SlackClient#on() is a deprecated method and will be removed in the next major version " +
"of hubot-slack. It is recommended not to use event handlers on the Slack clients directly. Please file an " +
"issue for any specific event type you need.\n" +
"Issue tracker: <https://github.com/slackapi/hubot-slack/issues>\n" +
"Event type: #{type}\n"
@rtm.on(type, callback)
###*
# Disconnect from the Slack RTM API
#
# @public
###
disconnect: ->
@rtm.disconnect()
# NOTE: removal of event listeners possibly does not belong in disconnect, because they are not added in connect.
@rtm.removeAllListeners()
###*
# Set a channel's topic
#
# @public
# @param {string} conversationId - Slack conversation ID
# @param {string} topic - new topic
###
setTopic: (conversationId, topic) ->
@robot.logger.debug "SlackClient#setTopic() with topic #{topic}"
# The `conversations.info` method is used to find out if this conversation can have a topic set
# NOTE: There's a performance cost to making this request, which can be avoided if instead the attempt to set the
# topic is made regardless of the conversation type. If the conversation type is not compatible, the call would
# fail, which is exactly the outcome in this implementation.
@web.conversations.info(conversationId)
.then (res) =>
conversation = res.channel
if !conversation.is_im && !conversation.is_mpim
return @web.conversations.setTopic(conversationId, topic)
else
@robot.logger.debug "Conversation #{conversationId} is a DM or MPDM. " +
"These conversation types do not have topics."
.catch (error) =>
@robot.logger.error "Error setting topic in conversation #{conversationId}: #{error.message}"
###*
# Send a message to Slack using the Web API.
#
# This method is usually called when a Hubot script is sending a message in response to an incoming message. The
# response object has a `send()` method, which triggers execution of all response middleware, and ultimately calls
# `send()` on the Adapter. SlackBot, the adapter in this case, delegates that call to this method; once for every item
# (since its method signature is variadic). The `envelope` is created by the Hubot Response object.
#
# This method can also be called when a script directly calls `robot.send()` or `robot.adapter.send()`. That bypasses
# the execution of the response middleware and directly calls into SlackBot#send(). In this case, the `envelope`
# parameter is up to the script.
#
# The `envelope.room` property is intended to be a conversation ID. Even when that is not the case, this method will
# makes a reasonable attempt at sending the message. If the property is set to a public or private channel name, it
# will still work. When there's no `room` in the envelope, this method will fallback to the `id` property. That
# affordance allows scripts to use Hubot User objects, Slack users (as obtained from the response to `users.info`),
# and Slack conversations (as obtained from the response to `conversations.info`) as possible envelopes. In the first
# two cases, envelope.id` will contain a user ID (`Uxxx` or `Wxxx`). Since Hubot runs using a bot token (`xoxb`),
# passing a user ID as the `channel` argument to `chat.postMessage` (with `as_user=true`) results in a DM from the bot
# user (if `as_user=false` it would instead result in a DM from slackbot). Leaving `as_user=true` has no effect when
# the `channel` argument is a conversation ID.
#
# NOTE: This method no longer accepts `envelope.room` set to a user name. Using it in this manner will result in a
# `channel_not_found` error.
#
# @public
# @param {Object} envelope - a Hubot Response envelope
# @param {Message} [envelope.message] - the Hubot Message that was received and generated the Response which is now
# being used to send an outgoing message
# @param {User} [envelope.user] - the Hubot User object representing the user who sent `envelope.message`
# @param {string} [envelope.room] - a Slack conversation ID for where `envelope.message` was received, usually an
# alias of `envelope.user.room`
# @param {string} [envelope.id] - a Slack conversation ID similar to `envelope.room`
# @param {string|Object} message - the outgoing message to be sent, can be a simple string or a key/value object of
# optional arguments for the Slack Web API method `chat.postMessage`.
###
send: (envelope, message) ->
room = envelope.room || envelope.id
if not room?
@robot.logger.error "Cannot send message without a valid room. Envelopes should contain a room property set to " +
"a Slack conversation ID."
return
@robot.logger.debug "SlackClient#send() room: #{room}, message: #{message}"
options =
as_user: true,
link_names: 1,
# when the incoming message was inside a thread, send responses as replies to the thread
# NOTE: consider building a new (backwards-compatible) format for room which includes the thread_ts.
# e.g. "#{conversationId} #{thread_ts}" - this would allow a portable way to say the message is in a thread
thread_ts: envelope.message?.thread_ts
if typeof message isnt "string"
@web.chat.postMessage(room, message.text, _.defaults(message, options))
.catch (error) =>
@robot.logger.error "SlackClient#send() error: #{error.message}"
else
@web.chat.postMessage(room, message, options)
.catch (error) =>
@robot.logger.error "SlackClient#send() error: #{error.message}"
###*
# Fetch users from Slack API using pagination
#
# @public
# @param {SlackClient~usersCallback} callback
###
loadUsers: (callback) ->
# some properties of the real results are left out because they are not used
combinedResults = { members: [] }
pageLoaded = (error, results) =>
return callback(error) if error
# merge results into combined results
combinedResults.members.push(member) for member in results.members
if results?.response_metadata?.next_cursor
# fetch next page
@web.users.list({
limit: SlackClient.PAGE_SIZE,
cursor: results.response_metadata.next_cursor
}, pageLoaded)
else
# pagination complete, run callback with results
callback(null, combinedResults)
@web.users.list({ limit: SlackClient.PAGE_SIZE }, pageLoaded)
###*
# Fetch user info from the brain. If not available, call users.info
# @public
###
fetchUser: (userId) ->
# User exists in the brain - retrieve this representation
return Promise.resolve(@robot.brain.data.users[userId]) if @robot.brain.data.users[userId]?
# User is not in brain - call users.info
# The user will be added to the brain in EventHandler
@web.users.info(userId).then((r) => @updateUserInBrain(r.user))
###*
# Fetch bot user info from the bot -> user map
# @public
###
fetchBotUser: (botId) ->
return Promise.resolve(@botUserIdMap[botId]) if @botUserIdMap[botId]?
# Bot user is not in mapping - call bots.info
@web.bots.info(bot: botId).then((r) => r.bot)
###*
# Fetch conversation info from conversation map. If not available, call conversations.info
# @public
###
fetchConversation: (conversationId) ->
# Current date minus 5 minutes (time of expiration for conversation info)
expiration = Date.now() - (5 * 60 * 1000)
# Check whether conversation is held in client's channelData map and whether information is expired
return Promise.resolve(@channelData[conversationId].channel) if @channelData[conversationId]?.channel? and
expiration < @channelData[conversationId]?.updated
# Delete data from map if it's expired
delete @channelData[conversationId] if @channelData[conversationId]?
# Return conversations.info promise
@web.conversations.info(conversationId).then((r) =>
if r.channel?
@channelData[conversationId] = {
channel: r.channel,
updated: Date.now()
}
r.channel
)
###*
# Will return a Hubot user object in Brain.
# User can represent a Slack human user or bot user
#
# The returned user from a message or reaction event is guaranteed to contain:
#
# id {String}: Slack user ID
# slack.is_bot {Boolean}: Flag indicating whether user is a bot
# name {String}: Slack username
# real_name {String}: Name of Slack user or bot
# room {String}: Slack channel ID for event (will be empty string if no channel in event)
#
# This may be called as a handler for `user_change` events or to update a
# a single user with its latest SlackUserInfo object.
#
# @private
# @param {SlackUserInfo|SlackUserChangeEvent} event_or_user - an object containing information about a Slack user
# that should be updated in the brain
###
updateUserInBrain: (event_or_user) ->
# if this method was invoked as a `user_change` event handler, unwrap the user from the event
user = if event_or_user.type == 'user_change' then event_or_user.user else event_or_user
# create a full representation of the user in the shape we persist for Hubot brain based on the parameter
# all top-level properties of the user are meant to be shared across adapters
newUser =
id: user.id
name: user.name
real_name: user.real_name
slack: {}
# don't create keys for properties that have no value, because the empty value will become authoritative
newUser.email_address = user.profile.email if user.profile?.email?
# all "non-standard" keys of a user are namespaced inside the slack property, so they don't interfere with other
# adapters (in case this hubot switched between adapters)
for key, value of user
newUser.slack[key] = value
# merge any existing representation of this user already stored in the brain into the new representation
if user.id of @robot.brain.data.users
for key, value of @robot.brain.data.users[user.id]
# the merge strategy is to only copy over data for keys that do not exist in the new representation
# this means the entire `slack` property is treated as one value
unless key of newUser
newUser[key] = value
# remove the existing representation and write the new representation to the brain
delete @robot.brain.data.users[user.id]
@robot.brain.userForId user.id, newUser
###*
# Processes events to fetch additional data or rearrange the shape of an event before handing off to the eventHandler
#
# @private
# @param {SlackRtmEvent} event - One of any of the events listed in <https://api.slack.com/events> with RTM enabled.
###
eventWrapper: (event) ->
if @eventHandler
# fetch full representations of the user, bot, and potentially the item_user.
fetches = {}
fetches.user = @fetchUser event.user if event.user
fetches.bot = @fetchBotUser event.bot_id if event.bot_id
fetches.item_user = @fetchUser event.item_user if event.item_user
# after fetches complete...
Promise.props(fetches)
.then (fetched) =>
# start augmenting the event with the fetched data
event.item_user = fetched.item_user if fetched.item_user
# assigning `event.user` properly depends on how the message was sent
if fetched.user
# messages sent from human users, apps with a bot user and using the bot token, and slackbot have the user
# property: this is preferred if its available
event.user = fetched.user
# fetched.bot will exist and be false if bot_id in @botUserIdMap
# but is from custom integration or app without bot user
else if fetched.bot
# fetched.bot is user representation of bot since it exists in botToUserMap
if @botUserIdMap[event.bot_id]
event.user = fetched.bot
# bot_id exists on all messages with subtype bot_message
# these messages only have a user_id property if sent from a bot user (xoxb token). therefore
# the above assignment will not happen for all messages from custom integrations or apps without a bot user
else if fetched.bot.user_id?
return @web.users.info(fetched.bot.user_id).then((res) =>
event.user = res.user
@botUserIdMap[event.bot_id] = res.user
return event
)
else
# bot doesn't have an associated user id
@botUserIdMap[event.bot_id] = false
event.user = {}
else
event.user = {}
return event
# once the event is fully populated...
.then (fetchedEvent) =>
# hand the event off to the eventHandler
try @eventHandler(fetchedEvent)
catch error then @robot.logger.error "An error occurred while processing an RTM event: #{error.message}."
# handle fetch errors
.catch (error) =>
@robot.logger.error "Incoming RTM message dropped due to error fetching info for a property: #{error.message}."
###*
# A handler for all incoming Slack events that are meaningful for the Adapter
#
# @callback SlackClient~eventHandler
# @param {Object} event
# @param {SlackUserInfo} event.user
# @param {string} event.channel
###
###*
# Callback that recieves a list of users
#
# @callback SlackClient~usersCallback
# @param {Error|null} error - an error if one occurred
# @param {Object} results
# @param {Array<SlackUserInfo>} results.members
###
module.exports = SlackClient