slapp
Version:
A module for Slack App integrations
728 lines (637 loc) • 19.5 kB
JavaScript
'use strict'
const request = require('request')
const slack = require('slack')
const Queue = require('js-queue')
const RATE_LIMIT = 'You are sending too many requests. Please relax.'
/**
* A Slack event message (command, action, event, etc.)
* @class Message
* @api private
*/
class Message {
/**
* Construct a new Message
*
* ##### Parameters
* - `type` the type of message (event, command, action, etc.)
*
* @param {string} type
* @param {Object} body
* @param {Object} meta
* @constructor
*/
constructor (type, body, meta) {
this.type = type
this.body = body || {}
this.meta = meta || {}
this.makeThreaded = null
this.conversation_id = [
this.meta.team_id,
this.meta.channel_id || 'nochannel',
this.meta.user_id || this.meta.bot_id || 'nouser'
].join('::')
this._slapp = null
this._queue = null
// allow clearTimeout to be stubbed
this.clearTimeout = clearTimeout
}
/**
* Attach a Slapp reference
*
* ##### Parameters
* - `slapp` instance of Slapp
*
* @param {Slapp} slapp
* @api private
*/
attachSlapp (slapp) {
this._slapp = slapp
}
/**
* Attach override handler in a conversation
*
* ##### Parameters
* - `fnKey` function key
* - `state` saved state to be passed onto router handler
*
*
* ##### Returns
* - `this` (chainable)
*
* @param {string} fnKey
* @param {Object} state
* @api private
*/
attachOverrideRoute (fnKey, state) {
let fn = this._slapp.getRoute(fnKey)
// TODO: should we bubble up if a function doesn't exist?
// It may be that it did exist but a new version was deployed that removed it.
// What do we do then?
if (fn) {
this.override = (msg) => {
return fn(msg, state)
}
}
return this
}
/**
* Attach response for cases the support responding directly to a request from Slack.
* This includes slash commands and message actions. If there is a response attached,
* `msg.respond` will use the request to respond, otherwise it will use the response_url
* to respond asynchronously. The `deadline` parameter controls how
* long to wait before timing out then close the HTTP request and fallback to the
* `response_url`.
*
* ##### Parameters
* - `response` fhttp response
* - `deadline` number of milliseconds before timing the response out
*
*
* ##### Returns
* - `this` (chainable)
*
* @param {Object} resp
* @param {number} deadline
* @api private
*/
attachResponse (response, deadline) {
let self = this
self._response = response
self._responseTimeout = setTimeout(() => {
if (self._response && !self._response.headersSent) {
let response = self._response
self._response = undefined
self._responseTimeout = undefined
response.send()
}
}, deadline)
return this
}
/**
* Clears the attached response if it exists and returns that response,
* returns null otherwise. Also clear the timeout.
*
* ##### Parameters
* - `options` `Object` options object. Supports `close`, if true then close response
*
* ##### Returns `response` if attached, otherwise null
*
* @param {Object} options
*
* @api private
*/
clearResponse (options) {
let self = this
options = options || {}
if (self._responseTimeout) {
self.clearTimeout(self._responseTimeout)
self._responseTimeout = undefined
}
if (self._response) {
let response = self._response
self._response = undefined
if (options.close) {
response.send()
}
return response
}
return null
}
/**
* May this message be responded to with `msg.respond` because the originating
* event included a `response_url`. If `hasResponse` returns false, you may
* still call `msg.respond` while explicitly passing a `response_url`.
*
* ##### Returns `true` if `msg.respond` may be called on this message, implicitly.
*
*/
hasResponse () {
let self = this
return !!self.body.response_url
}
/**
* Register the next function to route to in a conversation.
*
* The route should be registered already through `slapp.route`
*
* ##### Parameters
* - `fnKey` `string`
* - `state` `object` arbitrary data to be passed back to your function [optional]
* - `secondsToExpire` `number` - number of seconds to wait for the next message in the conversation before giving up. Default 60 minutes [optional]
*
*
* ##### Returns
* - `this` (chainable)
*
* @param {string} fnKey
* @param {Object} state
* @param {number} secondsToExpire
*/
route (fnKey, state, secondsToExpire) {
if (!state) {
state = {}
}
if (!secondsToExpire && secondsToExpire !== 0) {
secondsToExpire = this._slapp.defaultExpiration
}
let key = this.conversation_id
let expiration = secondsToExpire === 0
? null
: Date.now() + secondsToExpire * 1000
this._slapp.convoStore.set(key, { fnKey, state, expiration }, (err) => {
if (err) {
this._slapp.emit('error', err)
}
})
return this
}
/**
* Explicity cancel pending `route` registration.
*/
cancel () {
this._slapp.convoStore.del(this.conversation_id)
}
/**
* Send a message through [`chat.postmessage`](https://api.slack.com/methods/chat.postMessage).
*
* The current channel and inferred tokens are used as defaults. `input` maybe a
* `string`, `Object` or mixed `Array` of `strings` and `Objects`. If a string,
* the value will be set to `text` of the `chat.postmessage` object. Otherwise pass
* a [`chat.postmessage`](https://api.slack.com/methods/chat.postMessage) `Object`.
* If the current message is part of a thread, the new message will remain
* in the thread. To control if a message is threaded or not you can use the
* `msg.thread()` and `msg.unthread()` functions.
*
* If `input` is an `Array`, a random value in the array will be selected.
*
* ##### Parameters
* - `input` the payload to send, maybe a string, Object or Array.
* - `callback` (err, data) => {}
*
*
* ##### Returns
* - `this` (chainable)
*
* @param {(string|Object|Array)} input
* @param {function} callback
*/
say (input, callback) {
var self = this
if (!callback) callback = () => {}
input = self._processInput(input)
let payload = Object.assign({
token: self.meta.bot_token || self.meta.app_token,
channel: self.meta.channel_id
}, input)
// keep the message threaded unless we've "unthreaded" it
if (this.isThreaded() && this.makeThreaded !== false) {
payload.thread_ts = this.body.event.thread_ts
} else if (this.makeThreaded === true) {
payload.thread_ts = this.body.event.ts
}
self._queueRequest(() => {
slack.chat.postMessage(payload, (err, data) => {
if (err) {
self._slapp.emit('error', err)
}
callback(err, data)
self._queue.next()
})
})
return self
}
/**
* Respond to a Slash command, interactive message action, or interactive message options request.
*
* Slash commands and message actions responses should be passed a [`chat.postmessage`](https://api.slack.com/methods/chat.postMessage)
* payload. If `respond` is called within 3000ms (2500ms actually with a 500ms buffer) of the original request,
* the original request will be responded to instead or using the `response_url`. This will keep the
* action button spinner in sync with an awaiting update and is about 25% more responsive when tested.
*
* `input` options are the same as [`say`](#messagesay)
*
*
* If a response to an interactive message options request then an array of options should be passed
* like:
*
* {
* "options": [
* { "text": "value" },
* { "text": "value" }
* ]
* }
*
*
* ##### Parameters
* - `responseUrl` string - URL provided by a Slack interactive message action or slash command [optional]
* - `input` the payload to send, maybe a string, Object or Array.
* - `callback` (err, data) => {}
*
* Example:
*
* // responseUrl implied from body.response_url if this is an action or command
* msg.respond('thanks!', (err) => {})
*
* // responseUrl explicitly provided
* msg.respond(responseUrl, 'thanks!', (err) => {})
*
* // input provided as object
* msg.respond({ text: 'thanks!' }, (err) => {})
*
* // input provided as Array
* msg.respond(['thanks!', 'I :heart: u'], (err) => {})
*
*
* ##### Returns
* - `this` (chainable)
*
* @param {string} [responseUrl]
* @param {(string|Object|Array)} input
* @param {function} callback
*/
respond (responseUrl, input, callback) {
var self = this
if (!input || typeof input === 'function') {
callback = input
input = responseUrl
responseUrl = self.body.response_url
}
if (!callback) callback = () => {}
let response = self.clearResponse()
if (!responseUrl && !response) {
callback(new Error('no attached request and responseUrl not provided or not included as response_url with this type of Slack request'))
return self
}
if (response) {
response.send(input)
callback(null, {})
return self
}
self._queueRequest(() => {
self._request(responseUrl, self._processInput(input), (err, res, body) => {
// Normalize error for different error cases
if (!err && body.error) {
err = new Error(body.error)
}
if (!err && typeof body === 'string' && body.includes(RATE_LIMIT)) {
err = new Error('rate_limit')
}
if (err) {
self._slapp.emit('error', err)
callback(err)
return self._queue.next()
}
// success! clean up the response
delete body.ok
callback(null, body)
self._queue.next()
})
})
return self
}
/**
* Ensures all subsequent messages created are under a thread of the current message
*
* Example:
*
* // current msg is not part of a thread (i.e. does not have thread_ts set)
* msg.
* .say('This message will not be part of the thread and will be in the channel')
* .thread()
* .say('This message will remain in the thread')
* .say('This will also be in the thread')
*
* ##### Returns
* - `this` (chainable)
*
*/
thread () {
this.makeThreaded = true
return this
}
/**
* Ensures all subsequent messages created are not part of a thread
*
* Example:
*
* // current msg is part of a thread (i.e. has thread_ts set)
* msg.
* .say('This message will remain in the thread')
* .unthread()
* .say('This message will not be part of the thread and will be in the channel')
* .say('This will also not be part of the thread')
*
*
* ##### Returns
* - `this` (chainable)
*
*/
unthread () {
this.makeThreaded = false
return this
}
// TODO: PR this into smallwins/slack, below inspired by https://github.com/smallwins/slack/blob/master/src/_exec.js#L20
/* istanbul ignore next */
_request (responseUrl, input, callback) {
request({
uri: responseUrl,
method: 'POST',
json: input
}, callback)
}
/**
* Is this from a bot user?
*
* ##### Returns `bool` true if `this` is a message from a bot user
*/
isBot () {
return !!(this.meta.bot_id || (this.meta.user_id && this.meta.user_id === this.meta.bot_user_id))
}
/**
* Is this an `event` of type `message`?
*
*
* Marked as private until we figure out how to properly handle all of the other subtypes
* @api private
* ##### Returns `bool` true if `this` is a message event type
*/
isMessage () {
return !!(this.type === 'event' && this.body.event && this.body.event.type === 'message')
}
/**
* Is this an `event` of type `message` without any [subtype](https://api.slack.com/events/message)?
*
*
* ##### Returns `bool` true if `this` is a message event type with no subtype
*/
isBaseMessage () {
return this.isMessage() && !this.body.event.subtype
}
/**
* Is this an `event` of type `message` without any [subtype](https://api.slack.com/events/message)?
*
*
* ##### Returns `bool` true if `this` is an event that is part of a thread
*/
isThreaded () {
return this.body.event && !!this.body.event.thread_ts
}
/**
* Is this a message that is a direct mention ("@botusername: hi there", "@botusername goodbye!")
*
*
* ##### Returns `bool` true if `this` is a direct mention
*/
isDirectMention () {
return this.isBaseMessage() && new RegExp(`^<@${this.meta.bot_user_id}>`, 'i').test(this.body.event.text)
}
/**
* Is this a message in a direct message channel (one on one)
*
*
* ##### Returns `bool` true if `this` is a direct message
*/
isDirectMessage () {
return this.isBaseMessage() && this.meta.channel_id[0] === 'D'
}
/**
* Is this a message where the bot user mentioned anywhere in the message.
* Only checks for mentions of the bot user and does not consider any other users.
*
*
* ##### Returns `bool` true if `this` mentions the bot user
*/
isMention () {
return this.isBaseMessage() && new RegExp(`<@${this.meta.bot_user_id}>`, 'i').test(this.body.event.text)
}
/**
* Is this a message that's not a direct message or that mentions that bot at
* all (other users could be mentioned)
*
*
* ##### Returns `bool` true if `this` is an ambient message
*/
isAmbient () {
return this.isBaseMessage() && !this.isMention() && !this.isDirectMessage()
}
/**
* Is this a message that matches any one of the filters
*
* ##### Parameters
* - `messageFilters` Array - any of `direct_message`, `direct_mention`, `mention` and `ambient`
*
*
* ##### Returns `bool` true if `this` is a message that matches any of the filters
*
* @param {Array} of {string} messageFilters
*/
isAnyOf (messageFilters) {
let found = false
for (let i = 0; i < messageFilters.length; i++) {
var filter = messageFilters[i]
found = found || (filter === 'direct_message' && this.isDirectMessage())
found = found || (filter === 'direct_mention' && this.isDirectMention())
found = found || (filter === 'ambient' && this.isAmbient())
found = found || (filter === 'mention' && this.isMention())
}
return found
}
/**
* Return true if the event "team_id" is included in the "authed_teams" array.
* In other words, this event originated from a team who has installed your app
* versus a team who is sharing a channel with a team who has installed the app
* but in fact hasn't installed the app into that team explicitly.
* There are some events that do not include an "authed_teams" property. In these
* cases, error on the side of claiming this IS from an authed team.
*
* ##### Returns an Array of user IDs
*/
isAuthedTeam () {
// if the authed_teams property does not exist, error on the side of claiming it is an authed team_id
if (!Array.isArray(this.body.authed_teams)) {
return true
}
return this.body.authed_teams.indexOf(this.body.team_id) >= 0
}
/**
* Return the user IDs of any users mentioned in the message
*
* ##### Returns an Array of user IDs
*/
usersMentioned () {
return this._regexMentions(new RegExp('<@([UW][A-Za-z0-9]+)>', 'g'))
}
/**
* Return the channel IDs of any channels mentioned in the message
*
* ##### Returns an Array of channel IDs
*/
channelsMentioned () {
return this._regexMentions(new RegExp('<#(C[A-Za-z0-9]+)[^>]+>', 'g'))
}
/**
* Return the IDs of any subteams (groups) mentioned in the message
*
* ##### Returns an Array of subteam IDs
*/
subteamGroupsMentioned () {
return this._regexMentions(new RegExp('<!subteam\\^(S[A-Za-z0-9]+)[^>]+>', 'g'))
}
/**
* Was "@everyone" mentioned in the message
*
* ##### Returns `bool` true if `@everyone` was mentioned
*/
everyoneMentioned () {
return this._regexMentions(new RegExp('<!everyone>', 'g')).length > 0
}
/**
* Was the current "@channel" mentioned in the message
*
* ##### Returns `bool` true if `@channel` was mentioned
*/
channelMentioned () {
return this._regexMentions(new RegExp('<!(channel)[^>]*>', 'g')).length > 0
}
/**
* Was the "@here" mentioned in the message
*
* ##### Returns `bool` true if `@here` was mentioned
*/
hereMentioned () {
return this._regexMentions(new RegExp('<!(here)[^>]*>', 'g')).length > 0
}
/**
* Return the URLs of any links mentioned in the message
*
* ##### Returns `Array:string` of URLs of links mentioned in the message
*/
linksMentioned () {
let links = []
let re = new RegExp('<([^@^>]+)>', 'g')
let matcher
if (this.isBaseMessage()) {
do {
matcher = re.exec(this.body.event.text)
if (matcher) {
links.push(matcher[1].split('|')[0])
}
} while (matcher)
}
return links
}
/**
* Strip the direct mention prefix from the message text and return it. The
* original text is not modified
*
*
* ##### Returns `string` original `text` of message with a direct mention of the bot
* user removed. For example, `@botuser hi` or `@botuser: hi` would produce `hi`.
* `@notbotuser hi` would produce `@notbotuser hi`
*/
stripDirectMention () {
var text = ''
if (this.isBaseMessage()) {
text = this.body.event.text || ''
let match = text.match(new RegExp(`^<@${this.meta.bot_user_id}>:{0,1}(.*)`))
if (match) {
text = match[1].trim()
}
}
return text
}
/**
* ##### Returns array of regex matches from the text of a message
*
* @api private
*/
_regexMentions (re) {
let matches = []
let matcher
if (this.isBaseMessage()) {
do {
matcher = re.exec(this.body.event.text)
if (matcher) {
matches.push(matcher[1])
}
} while (matcher)
}
return matches
}
/**
* Preprocess `chat.postmessage` input.
*
* If an array, pick a random item of the array.
* If a string, wrap in a `chat.postmessage` params object
*
* @api private
*/
_processInput (input) {
// if input is an array, randomly pick one of the values
if (Array.isArray(input)) {
input = input[Math.floor(Math.random() * input.length)]
}
if (typeof input === 'string') {
input = {
text: input
}
}
return input
}
_queueRequest (fn) {
if (!this._queue) {
this._queue = new Queue()
}
this._queue.add(fn)
}
verifyProps () {
let missing = []
if (!this.meta.app_token) missing.push('app_token')
if (!this.meta.team_id) missing.push('team_id')
if (missing.length === 0) {
return null
}
return new Error(`Cannot process message because the following properties are missing from message.meta: ${missing.join(',')}`)
}
}
module.exports = Message