UNPKG

hubot-slack

Version:
320 lines (271 loc) 14.4 kB
{Adapter, TextMessage, EnterMessage, LeaveMessage, TopicMessage, CatchAllMessage} = require.main.require "hubot" {SlackTextMessage, ReactionMessage, PresenceMessage, FileSharedMessage} = require "./message" SlackClient = require "./client" pkg = require "../package" class SlackBot extends Adapter ###* # Slackbot is an adapter for connecting Hubot to Slack # @constructor # @param {Robot} robot - the Hubot robot # @param {Object} options - configuration options for the adapter # @param {string} options.token - authentication token for Slack APIs # @param {Boolean} options.disableUserSync - disables syncing all user data on start # @param {Object} options.rtm - RTM configuration options for SlackClient # @param {Object} options.rtmStart - options for `rtm.start` Web API method ### constructor: (@robot, @options) -> super @robot.logger.info "hubot-slack adapter v#{pkg.version}" @client = new SlackClient @options, @robot ### # Hubot Adapter methods ### ###* # Slackbot initialization # @public ### run: -> # Token validation return @robot.logger.error "No token provided to Hubot" unless @options.token return @robot.logger.error "Invalid token provided, please follow the upgrade instructions" unless (@options.token.substring(0, 5) in ["xoxb-", "xoxp-"]) # SlackClient event handlers @client.rtm.on "open", @open @client.rtm.on "close", @close @client.rtm.on "error", @error @client.rtm.on "authenticated", @authenticated @client.onEvent @eventHandler # TODO: set this to false as soon as RTM connection closes (even if reconnect will happen later) # TODO: check this value when connection finishes (even if its a reconnection) # TODO: build a map of enterprise users and local users @needsUserListSync = true unless @options.disableUserSync # Synchronize workspace users to brain @client.loadUsers @usersLoaded else @brainIsLoaded = true # Brain will emit 'loaded' the first time it connects to its storage and then again each time a key is set @robot.brain.on "loaded", () => if not @brainIsLoaded @brainIsLoaded = true # The following code should only run after the first time the brain connects to its storage # There's a race condition where the connection can happen after the above `@client.loadUsers` call finishes, # in which case the calls to save users in `@usersLoaded` would not persist. It is still necessary to call the # method there in the case Hubot is running without brain storage. # NOTE: is this actually true? won't the brain have the users in memory and persist to storage as soon as the # connection is complete? # NOTE: this seems wasteful. when there is brain storage, it will end up loading all the users twice. @client.loadUsers @usersLoaded @isLoaded = true # NOTE: will this only subscribe a partial user list because loadUsers has not yet completed? it will at least # subscribe to the users that were stored in the brain from the last run. @presenceSub() # Start logging in @client.connect() ###* # Hubot is sending a message to Slack # # @public # @param {Object} envelope - fully documented in SlackClient # @param {...(string|Object)} messages - fully documented in SlackClient ### send: (envelope, messages...) -> # TODO: if the sender is interested in the completion, the last item in `messages` will be a function for message in messages # NOTE: perhaps do envelope manipulation here instead of in the client (separation of concerns) @client.send(envelope, message) unless message is "" ###* # Hubot is replying to a Slack message # @public # @param {Object} envelope - fully documented in SlackClient # @param {...(string|Object)} messages - fully documented in SlackClient ### reply: (envelope, messages...) -> # TODO: if the sender is interested in the completion, the last item in `messages` will be a function for message in messages if message isnt "" # TODO: channel prefix matching should be removed message = "<@#{envelope.user.id}>: #{message}" unless envelope.room[0] is "D" @client.send envelope, message ###* # Hubot is setting the Slack conversation topic # @public # @param {Object} envelope - fully documented in SlackClient # @param {...string} strings - strings that will be newline separated and set to the conversation topic ### setTopic: (envelope, strings...) -> # TODO: if the sender is interested in the completion, the last item in `messages` will be a function # TODO: this will fail if sending an object as a value in strings @client.setTopic envelope.room, strings.join("\n") ###* # Hubot is sending a reaction # NOTE: the super class implementation is just an alias for send, but potentially, we can detect # if the envelope has a specific message and send a reactji. the fallback would be to just send the # emoji as a message in the channel ### # emote: (envelope, strings...) -> ### # SlackClient event handlers ### ###* # Slack client has opened the connection # @private ### open: => @robot.logger.info "Connected to Slack RTM" # Tell Hubot we're connected so it can load scripts @emit "connected" ###* # Slack client has authenticated # # @private # @param {SlackRtmStart|SlackRtmConnect} identity - the response from calling the Slack Web API method `rtm.start` or # `rtm.connect` ### authenticated: (identity) => {@self, team} = identity # Find out bot_id # NOTE: this information can be fetched by using `bots.info` combined with `users.info`. This presents an # alternative that decouples Hubot from `rtm.start` and would make it compatible with `rtm.connect`. if identity.users for user in identity.users if user.id == @self.id @robot.logger.debug "SlackBot#authenticated() Found self in RTM start data" @self.bot_id = user.profile.bot_id break # Provide name to Hubot so it can be used for matching in `robot.respond()`. This must be a username, despite the # deprecation, because the SlackTextMessage#buildText() will format mentions into `@username`, and that is what # Hubot will be comparing to the message text. @robot.name = @self.name @robot.logger.info "Logged in as @#{@robot.name} in workspace #{team.name}" ###* # Creates a presense subscripton for all users in the brain # @private ### presenceSub: => # Only subscribe to status changes from human users that are not deleted ids = for own id, user of @robot.brain.data.users when (not user.is_bot and not user.deleted) id @robot.logger.debug "SlackBot#presenceSub() Subscribing to presence for #{ids.length} users" @client.rtm.subscribePresence ids ###* # Slack client has closed the connection # @private ### close: => @robot.logger.info "Disconnected from Slack RTM" # NOTE: not confident that @options.autoReconnect works if @options.autoReconnect @robot.logger.info "Waiting for reconnect..." else @robot.logger.info "Exiting..." @client.disconnect() # NOTE: Node recommends not to call process.exit() but Hubot itself uses this mechanism for shutting down # Can we make sure the brain is flushed to persistence? Do we need to cleanup any state (or timestamp anything)? process.exit 1 ###* # Slack client received an error # # @private # @param {SlackRtmError} error - An error emitted from the [Slack RTM API](https://api.slack.com/rtm) ### error: (error) => @robot.logger.error "Slack RTM error: #{JSON.stringify error}" # Assume that scripts can handle slowing themselves down, all other errors are bubbled up through Hubot # NOTE: should rate limit errors also bubble up? if error.code isnt -1 @robot.emit "error", error ###* # Incoming Slack event handler # # This method is used to ingest Slack RTM events and prepare them as Hubot Message objects. The messages are passed # to the Robot#receive() method, which allows executes receive middleware and eventually triggers the various # listeners that are created by scripts. # # Depending on the exact type of event, additional properties may be present. The following describes the "special" # ones that have meaningful handling across all types. # # @private # @param {Object} event # @param {string} event.type - this specifies the event type # @param {SlackUserInfo} [event.user] - the description of the user creating this event as returned by `users.info` # @param {string} [event.channel] - the conversation ID for where this event took place # @param {SlackBotInfo} [event.bot] - the description of the bot creating this event as returned by `bots.info` ### eventHandler: (event) => {user, channel} = event # Ignore anything we sent return if user?.id is @self.id # Send to Hubot based on message type if event.type is "message" # Hubot expects all user objects to have a room property that is used in the envelope for the message after it # is received user.room = if channel? then channel else "" switch event.subtype when "bot_message" @robot.logger.debug "Received text message in channel: #{channel}, from: #{user.id} (bot)" SlackTextMessage.makeSlackTextMessage(user, undefined, undefined, event, channel, @robot.name, @robot.alias, @client, (error, message) => return @robot.logger.error "Dropping message due to error #{error.message}" if error @receive message ) when "channel_topic", "group_topic" @robot.logger.debug "Received topic change message in conversation: #{channel}, new topic: #{event.topic}, set by: #{user.id}" @receive new TopicMessage user, event.topic, event.ts when undefined @robot.logger.debug "Received text message in channel: #{channel}, from: #{user.id} (human)" SlackTextMessage.makeSlackTextMessage(user, undefined, undefined, event, channel, @robot.name, @robot.alias, @client, (error, message) => return @robot.logger.error "Dropping message due to error #{error.message}" if error @receive message ) else if event.type is "member_joined_channel" # this event type always has a channel user.room = channel @robot.logger.debug "Received enter message for user: #{user.id}, joining: #{channel}" @receive new EnterMessage user else if event.type is "member_left_channel" # this event type always has a channel user.room = channel @robot.logger.debug "Received leave message for user: #{user.id}, joining: #{channel}" @receive new LeaveMessage user else if event.type is "reaction_added" or event.type is "reaction_removed" # Once again Hubot expects all user objects to have a room property that is used in the envelope for the message # after it is received. If the reaction is to a message, then the `event.item.channel` contain a conversation ID. # Otherwise reactions can be on files and file comments, which are "global" and aren't contained in a # conversation. In that situation we fallback to an empty string. user.room = if event.item.type is "message" then event.item.channel else "" # Reaction messages may contain an `event.item_user` property containing a fetched SlackUserInfo object. Before # the message is received by Hubot, turn that data into a Hubot User object. item_user = if event.item_user? then @robot.brain.userForId event.item_user.id, event.item_user else {} @robot.logger.debug "Received reaction message from: #{user.id}, reaction: #{event.reaction}, item type: #{event.item.type}" @receive new ReactionMessage(event.type, user, event.reaction, item_user, event.item, event.event_ts) else if event.type is "presence_change" # Collect all Hubot User objects referenced in this presence change event # NOTE: this does not create new Hubot User objects for any users that are not already in the brain. It should # not be possible for this to happen since Slack will only send events for users where an explicit subscription # was made. In the `presenceSub()` method, subscriptions are only made for users in the brain. users = for user_id in (event.users or [event.user.id]) when @robot.brain.data.users[user_id]? @robot.brain.data.users[user_id] @robot.logger.debug "Received presence update message for users: #{u.id for u in users} with status: #{event.presence}" @receive new PresenceMessage(users, event.presence) else if event.type is "file_shared" # Once again Hubot expects all user objects to have a room property that is used in the envelope for the message # after it is received. If the reaction is to a message, then the `event.item.channel` contain a conversation ID. # Otherwise reactions can be on files and file comments, which are "global" and aren't contained in a # conversation. In that situation we fallback to an empty string. user.room = event.channel_id @robot.logger.debug "Received file_shared message from: #{user.id}, file_id: #{event.file_id}" @receive new FileSharedMessage(user, event.file_id, event.event_ts) # NOTE: we may want to wrap all other incoming events as a generic Message # else ###* # Callback for fetching all users in workspace. Delegates to `updateUserInBrain()` to write all users to Hubot brain # # @private # @param {Error} [error] - describes an error that occurred while fetching users # @param {SlackUsersList} [res] - the response from the Slack Web API method `users.list` ### usersLoaded: (err, res) => if err || !res.members.length @robot.logger.error "Can't fetch users" return @client.updateUserInBrain member for member in res.members module.exports = SlackBot