UNPKG

hubot-slack

Version:
405 lines (361 loc) 18.1 kB
_ = 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 ###* # Number of milliseconds which the information returned by `conversations.info` is considered to be valid. The default # value is 5 minutes, and it can be customized by setting the `HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS` environment # variable. Setting this number higher will reduce the number of requests made to the Web API, which may be helpful if # your Hubot is experiencing rate limiting errors. However, setting this number too high will result in stale data # being referenced, and your scripts may experience errors related to channel info (like the name) being incorrect # after a user changes it in Slack. # @private ### @CONVERSATION_CACHE_TTL_MS = if process.env.HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS then parseInt(process.env.HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS, 10) else (5 * 60 * 1000) ###* # @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 = { "B01": { id: "B01", user_id: "USLACKBOT" } } # 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 "file_shared", @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 time of expiration for conversation info expiration = Date.now() - SlackClient.CONVERSATION_CACHE_TTL_MS # 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 ### if SlackClient.CONVERSATION_CACHE_TTL_MS is NaN throw new Error('HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS must be a number. It could not be parsed.') module.exports = SlackClient