From bb9cc57f44b3dcd7386ee62a326059985abf106d Mon Sep 17 00:00:00 2001 From: Joey Guerra Date: Sat, 23 Sep 2023 22:12:21 -0500 Subject: [PATCH] feat: Upgrade to Hubot 9, async/await (#30) * feat: Upgrade to Hubot 9, async/await BREAKING CHANGE: Updated send/reply/setTopic methods to async/await. * feat: Update to Hubot 9 feat: Enable handling channel topic changes --- index.js | 4 +- package-lock.json | 2 +- package.json | 2 +- src/bot.js | 122 +++++++------ src/client.js | 445 --------------------------------------------- src/message.js | 54 +++--- test/bot.js | 31 +--- test/client.js | 446 ---------------------------------------------- test/message.js | 243 +++++++++++-------------- test/stubs.js | 8 +- 10 files changed, 213 insertions(+), 1144 deletions(-) delete mode 100644 src/client.js delete mode 100644 test/client.js diff --git a/index.js b/index.js index 7fcc5186..af4279a0 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ -const SlackBot = require('./src/bot'); -require('./src/extensions'); +const SlackBot = require('./src/bot.js').SlackBot; +require('./src/extensions.js'); exports.use = function(robot) { let e; diff --git a/package-lock.json b/package-lock.json index f816573a..a1f1cfbc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "npm": ">= 9.5.1" }, "peerDependencies": { - "hubot": ">= 7.0.0" + "hubot": ">= 9.0.0" } }, "node_modules/@slack/socket-mode": { diff --git a/package.json b/package.json index b7067eba..0651ef5c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@slack/web-api": "^6.8.1" }, "peerDependencies": { - "hubot": ">= 7.0.0" + "hubot": ">= 9.0.0" }, "engines": { "node": ">= 18.16.0", diff --git a/src/bot.js b/src/bot.js index 8db892df..b9cb7ee8 100644 --- a/src/bot.js +++ b/src/bot.js @@ -20,7 +20,7 @@ class SlackClient { this.apiPageSize = parseInt(options.apiPageSize, 10); } - this.robot.logger.debug(`SocketModeClient initialized with options: ${JSON.stringify(options.socketModeOptions)}`); + this.robot.logger.debug(`SocketModeClient initialized with options: ${JSON.stringify(options.socketModeOptions) ?? ''}`); // Map to convert bot user IDs (BXXXXXXXX) to user representations for events from custom // integrations and apps without a bot user @@ -58,23 +58,21 @@ class SlackClient { return this.socket.removeAllListeners(); } - setTopic(conversationId, topic) { + async setTopic(conversationId, topic) { this.robot.logger.debug(`SlackClient#setTopic() with topic ${topic}`); - return this.web.conversations.info({channel: conversationId}) - .then(res => { - const conversation = res.channel; - if (!conversation.is_im && !conversation.is_mpim) { - return this.web.conversations.setTopic({channel: conversationId, topic}); - } else { - return this.robot.logger.debug(`Conversation ${conversationId} is a DM or MPDM. ` + - "These conversation types do not have topics." - ); - } - }).catch(error => { - return this.robot.logger.error(error, `Error setting topic in conversation ${conversationId}: ${error.message}`); - }); + try { + const res = await this.web.conversations.info({channel: conversationId}) + const conversation = res.channel; + if (!conversation.is_im && !conversation.is_mpim) { + return this.web.conversations.setTopic({channel: conversationId, topic}); + } else { + return this.robot.logger.debug(`Conversation ${conversationId} is a DM or MPDM. These conversation types do not have topics.`); + } + } catch (error) { + this.robot.logger.error(error, `Error setting topic in conversation ${conversationId}: ${error.message}`); + } } - send(envelope, message) { + async send(envelope, message) { const room = envelope.room || envelope.id; if (room == null) { this.robot.logger.error("Cannot send message without a valid room. Envelopes should contain a room property set to a Slack conversation ID."); @@ -83,13 +81,19 @@ class SlackClient { this.robot.logger.debug(`SlackClient#send() room: ${room}, message: ${message}`); if (typeof message !== "string") { message.channel = room - return this.web.chat.postMessage(message).then(result => { + try { + const result = await this.web.chat.postMessage(message) this.robot.logger.debug(`Successfully sent message to ${room}`) - }).catch(e => this.robot.logger.error(e, `SlackClient#send(message) error: ${e.message}`)) + } catch (e) { + this.robot.logger.error(e, `SlackClient#send(message) error: ${e.message}`) + } } else { - return this.web.chat.postMessage({ channel: room, text: message }).then(result => { + try { + const result = await this.web.chat.postMessage({ channel: room, text: message }) this.robot.logger.debug(`Successfully sent message (string) to ${room}`) - }).catch(e => this.robot.logger.error(e, `SlackClient#send(string) error: ${e.message}`)) + } catch (e) { + this.robot.logger.error(e, `SlackClient#send(string) error: ${e.message}`) + } } } loadUsers(callback) { @@ -121,20 +125,19 @@ class SlackClient { this.updateUserInBrain(r.user); return r.user; } - fetchConversation(conversationId) { + async fetchConversation(conversationId) { const expiration = Date.now() - SlackClient.CONVERSATION_CACHE_TTL_MS; if (((this.channelData[conversationId] != null ? this.channelData[conversationId].channel : undefined) != null) && (expiration < (this.channelData[conversationId] != null ? this.channelData[conversationId].updated : undefined))) { return Promise.resolve(this.channelData[conversationId].channel); } if (this.channelData[conversationId] != null) { delete this.channelData[conversationId]; } - return this.web.conversations.info({channel: conversationId}).then(r => { - if (r.channel != null) { - this.channelData[conversationId] = { - channel: r.channel, - updated: Date.now() - }; - } - return r.channel; - }); + const r = await this.web.conversations.info({channel: conversationId}) + if (r.channel != null) { + this.channelData[conversationId] = { + channel: r.channel, + updated: Date.now() + }; + } + return r.channel; } updateUserInBrain(event_or_user) { let key, value; @@ -246,7 +249,7 @@ class SlackBot extends Adapter { * @param {Object} envelope - fully documented in SlackClient * @param {...(string|Object)} messages - fully documented in SlackClient */ - send(envelope, ...messages) { + async send(envelope, ...messages) { this.robot.logger.debug('Sending message to Slack'); let callback = function() {}; if (typeof(messages[messages.length - 1]) === "function") { @@ -257,7 +260,15 @@ class SlackBot extends Adapter { // NOTE: perhaps do envelope manipulation here instead of in the client (separation of concerns) if (message !== "") { return this.client.send(envelope, message); } }); - return Promise.all(messagePromises).then(callback.bind(null, null), callback); + let results = []; + try { + results = await Promise.all(messagePromises) + callback(null, null) + } catch (e) { + this.robot.logger.error(e); + callback(e, null); + } + return results; } /** @@ -266,7 +277,7 @@ class SlackBot extends Adapter { * @param {Object} envelope - fully documented in SlackClient * @param {...(string|Object)} messages - fully documented in SlackClient */ - reply(envelope, ...messages) { + async reply(envelope, ...messages) { this.robot.logger.debug('replying to message'); let callback = function() {}; if (typeof(messages[messages.length - 1]) === "function") { @@ -283,7 +294,15 @@ class SlackBot extends Adapter { return this.client.send(envelope, message); } }); - return Promise.all(messagePromises).then(callback.bind(null, null), callback); + let results = []; + try { + results = await Promise.all(messagePromises) + callback(null, null) + } catch (e) { + this.robot.logger.error(e); + callback(e, null); + } + return results; } /** @@ -292,10 +311,8 @@ class SlackBot extends Adapter { * @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 - return this.client.setTopic(envelope.room, strings.join("\n")); + async setTopic(envelope, ...strings) { + return await this.client.setTopic(envelope.room, strings.join("\n")); } /** @@ -457,16 +474,13 @@ class SlackBot extends Adapter { // Hubot expects all user objects to have a room property that is used in the envelope for the message after it // is received from.room = channel ?? ''; - - // looks like sometimes the profile is not set - if (from.profile) { - from.name = from.profile.display_name; - } + from.name = from?.profile?.display_name ?? null; // add the bot id to the message if it's a direct message message.body.event.text = this.addBotIdToMessage(message.body.event); message.body.event.text = this.replaceBotIdWithName(message.body.event); this.robot.logger.debug(`Text = ${message.body.event.text}`); + this.robot.logger.debug(`Event subtype = ${message.body.event?.subtype}`); try { switch (message.event.type) { case "member_joined_channel": @@ -474,13 +488,13 @@ class SlackBot extends Adapter { this.robot.logger.debug(`Received enter message for user: ${from.id}, joining: ${channel}`); msg = new EnterMessage(from); msg.ts = message.event.ts; - this.receive(msg); + await this.receive(msg); break; case "member_left_channel": this.robot.logger.debug(`Received leave message for user: ${from.id}, joining: ${channel}`); msg = new LeaveMessage(user); msg.ts = message.ts; - this.receive(msg); + await this.receive(msg); break; case "reaction_added": case "reaction_removed": // Once again Hubot expects all user objects to have a room property that is used in the envelope for the message @@ -494,18 +508,20 @@ class SlackBot extends Adapter { const item_user = (message.body.event.item_user != null) ? this.robot.brain.userForId(message.body.event.item_user.id, message.body.event.item_user) : {}; this.robot.logger.debug(`Received reaction message from: ${from.id}, reaction: ${message.body.event.reaction}, item type: ${message.body.event.item.type}`); - this.receive(new ReactionMessage(message.body.event.type, from, message.body.event.reaction, item_user, message.body.event.item, message.body.event.event_ts)); + await this.receive(new ReactionMessage(message.body.event.type, from, message.body.event.reaction, item_user, message.body.event.item, message.body.event.event_ts)); break; case "file_shared": this.robot.logger.debug(`Received file_shared message from: ${message.body.event.user_id}, file_id: ${message.body.event.file_id}`); - this.receive(new FileSharedMessage(from, message.body.event.file_id, message.body.event.event_ts)); + await this.receive(new FileSharedMessage(from, message.body.event.file_id, message.body.event.event_ts)); break; default: this.robot.logger.debug(`Received generic message: ${message.event.type}`); - SlackTextMessage.makeSlackTextMessage(from, null, message?.body?.event.text, message?.body?.event, channel, this.robot.name, this.robot.alias, this.client, (error, message) => { - if (error) { return this.robot.logger.error(error, `Dropping message due to error ${error.message}`); } - return this.receive(message); - }); + try { + const msg = await SlackTextMessage.makeSlackTextMessage(from, null, message?.body?.event.text, message?.body?.event, channel, this.robot.name, this.robot.alias, this.client) + await this.receive(msg); + } catch (error) { + this.robot.logger.error(error, `Dropping message due to error ${error.message}`); + } break; } } catch (e) { @@ -532,5 +548,5 @@ class SlackBot extends Adapter { return res.members.map((member) => this.client.updateUserInBrain(member)); } } - -module.exports = SlackBot; +module.exports.SlackClient = SlackClient; +module.exports.SlackBot = SlackBot; diff --git a/src/client.js b/src/client.js deleted file mode 100644 index 151c6163..00000000 --- a/src/client.js +++ /dev/null @@ -1,445 +0,0 @@ -const EventEmitter = require('events'); -const SocketModeClient = require('@slack/socket-mode').SocketModeClient; -const WebClient = require('@slack/web-api').WebClient; - -class SlackClient extends EventEmitter { - /** - * 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 - */ - static CONVERSATION_CACHE_TTL_MS = - process.env.HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS - ? parseInt(process.env.HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS, 10) - : (5 * 60 * 1000); - - /** - * @constructor - * @param {Object} options - Configuration options for this SlackClient instance - * @param {string} options.token - Slack API token for authentication - * @param {string} options.apiPageSize - Number used for limit when making paginated requests to Slack Web API list methods - * @param {Object} [options.socketModeOptions={}] - Configuration options for owned SocketModeClient instance - * @param {Robot} robot - Hubot robot instance - */ - constructor(options, robot) { - super(); - this.robot = robot; - this.socket = new SocketModeClient({ appToken: options.appToken, ...options.socketModeOptions }); - this.web = new WebClient(options.botToken, { - logger: robot.logger, - logLevel: options.logLevel ?? 'info', - maxRequestConcurrency: options?.maxRequestConcurrency ?? 1, - retryConfig: options?.retryConfig, - agent: options?.agent, - tls: options?.tls, - timeout: options?.timeout, - rejectRateLimitedCalls: options?.rejectRateLimitedCalls, - headers: options?.headers, - teamId: options?.teamId - }); - - this.apiPageSize = 100; - if (!isNaN(options.apiPageSize)) { - this.apiPageSize = parseInt(options.apiPageSize, 10); - } - - this.robot.logger.debug(`SocketModeClient initialized with options: ${JSON.stringify(options?.socketModeOptions)}`); - - // Map to convert bot user IDs (BXXXXXXXX) to user representations for events from custom - // integrations and apps without a bot user - this.botUserIdMap = { - "B01": { id: "B01", user_id: "USLACKBOT" } - }; - - // Map to convert conversation IDs to conversation representations - this.channelData = {}; - - // Event handling - // NOTE: add channel join and leave events - this.socket.on('authenticated', this.eventWrapper, this); - this.socket.on("message", this.eventWrapper, this); - this.socket.on("reaction_added", this.eventWrapper, this); - this.socket.on("reaction_removed", this.eventWrapper, this); - this.socket.on("member_joined_channel", this.eventWrapper, this); - this.socket.on("member_left_channel", this.eventWrapper, this); - this.socket.on("file_shared", this.eventWrapper, this); - this.socket.on("user_change", this.updateUserInBrain, this); - this.eventHandler = undefined; - } - - /** - * Open connection to the Slack Socket - * - * @public - */ - async connect() { - this.robot.logger.debug(`SocketModeClient#start()`); - let startResponse = null; - try { - startResponse = await this.socket.start() - this.robot.logger.info(startResponse); - const response = await this.web.auth.test(); - this.robot.self = response.user; - this.robot.logger.info('Connected to Slack after starting socket client.'); - // this.emit('connected'); - } catch (e) { - this.robot.logger.error(e, `Error connecting to Slack: ${e.message}`); - this.emit('error', e); - } - return startResponse; - } - - /** - * Set event handler - * - * @public - * @param {SlackClient~eventHandler} callback - */ - onEvent(callback) { - if (this.eventHandler !== callback) { - return this.eventHandler = callback; - } - } - - /** - * Disconnect from the Slack Socket - * - * @public - */ - disconnect() { - this.socket.disconnect(); - // NOTE: removal of event listeners possibly does not belong in disconnect, because they are not added in connect. - return this.socket.removeAllListeners(); - } - - /** - * Set a channel's topic - * - * @public - * @param {string} conversationId - Slack conversation ID - * @param {string} topic - new topic - */ - async setTopic(conversationId, topic) { - this.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. - try { - const res = await this.web.conversations.info({channel: conversationId}) - const conversation = res.channel; - if (!conversation.is_im && !conversation.is_mpim) { - return await this.web.conversations.setTopic({channel: conversationId, topic}); - } else { - return this.robot.logger.debug(`Conversation ${conversationId} is a DM or MPDM. These conversation types do not have topics.`); - } - } catch (e) { - this.robot.logger.error(e, `Error setting topic in conversation ${conversationId}: ${e.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) { - const room = envelope.room || envelope.id; - if ((room == null)) { - this.robot.logger.error("Cannot send message without a valid room. Envelopes should contain a room property set to " + - "a Slack conversation ID." - ); - return; - } - - this.robot.logger.debug(`SlackClient#send() room: ${room}, message: ${message}`); - - const 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 != null ? envelope.message.thread_ts : undefined) - }; - - if (typeof message !== "string") { - return this.web.chat.postMessage({channel: room, text: message.text}, Object.assign(message, options)) - .catch(error => { - return this.robot.logger.error(error, `SlackClient#send() error: ${error.message}`); - }); - } else { - return this.web.chat.postMessage({channel: room, text: message.text}, options) - .catch(error => { - return this.robot.logger.error(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 - const combinedResults = { members: [] }; - var pageLoaded = (error, results) => { - if (error) { return callback(error); } - // merge results into combined results - for (var member of results.members) { combinedResults.members.push(member); } - - if(results?.response_metadata?.next_cursor) { - // fetch next page - return this.web.users.list({ - limit: this.apiPageSize, - cursor: results.response_metadata.next_cursor - }, pageLoaded); - } else { - // pagination complete, run callback with results - return callback(null, combinedResults); - } - }; - return this.web.users.list({ limit: this.apiPageSize }, pageLoaded); - } - - /** - * Fetch user info from the brain. If not available, call users.info - * @public - */ - async fetchUser(userId) { - // User exists in the brain - retrieve this representation - if (this.robot.brain.data.users[userId] != null) { - return Promise.resolve(this.robot.brain.data.users[userId]); - } - - // User is not in brain - call users.info - // The user will be added to the brain in EventHandler - const r = await this.web.users.info({user: userId}) - return this.updateUserInBrain(r.user); - } - - /** - * Fetch bot user info from the bot -> user map - * @public - */ - async fetchBotUser(botId) { - if (this.botUserIdMap[botId] != null) { - return Promise.resolve(this.botUserIdMap[botId]); - } - - // Bot user is not in mapping - call bots.info - this.robot.logger.debug(`SlackClient#fetchBotUser() Calling bots.info API for bot_id: ${botId}`); - const r = await this.web.bots.info({bot: botId}) - return r.bot; - } - - /** - * Fetch conversation info from conversation map. If not available, call conversations.info - * @public - */ - async fetchConversation(conversationId) { - // Current date minus time of expiration for conversation info - const expiration = Date.now() - SlackClient.CONVERSATION_CACHE_TTL_MS; - - // Check whether conversation is held in client's channelData map and whether information is expired - if (((this.channelData[conversationId] != null ? this.channelData[conversationId].channel : undefined) != null) && - (expiration < (this.channelData[conversationId] != null ? this.channelData[conversationId].updated : undefined))) { return Promise.resolve(this.channelData[conversationId].channel); } - - // Delete data from map if it's expired - if (this.channelData[conversationId] != null) { delete this.channelData[conversationId]; } - - // Return conversations.info promise - const r = await this.web.conversations.info({channel: conversationId}) - if (r.channel != null) { - this.channelData[conversationId] = { - channel: r.channel, - updated: Date.now() - }; - } - return 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 - let key, value; - const user = event_or_user.type === 'user_change' ? event_or_user.user : 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 - const 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 - if ((user.profile != null ? user.profile.email : undefined) != null) { newUser.email_address = 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 in user) { - value = user[key]; - newUser.slack[key] = value; - } - - // merge any existing representation of this user already stored in the brain into the new representation - if (user.id in this.robot.brain.data.users) { - for (key in this.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 - value = this.robot.brain.data.users[user.id][key]; - if (!(key in newUser)) { - newUser[key] = value; - } - } - } - - // remove the existing representation and write the new representation to the brain - delete this.robot.brain.data.users[user.id]; - return this.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 {SlackEvent} event - One of any of the events listed in with Events API enabled. - */ - eventWrapper(event) { - if (this.eventHandler) { - // fetch full representations of the user, bot, and potentially the item_user. - const fetches = []; - if (event.user) { - fetches.push(this.fetchUser(event.user)); - } else if (event.bot_id) { - fetches.push(this.fetchBotUser(event.bot_id)); - } - - if (event.item_user) { - fetches.push(this.fetchUser(event.item_user)); - } - // after fetches complete... - return Promise.all(fetches) - .then(fetched => { - if (event.user) { - event.user = fetched.shift(); - } else if (event.bot_id) { - let bot = fetched.shift(); - if (this.botUserIdMap[event.bot_id]) { - event.user = 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 (bot.user_id != null) { - return this.web.users.info({user: bot.user_id}).then(res => { - event.user = res.user; - this.botUserIdMap[event.bot_id] = res.user; - return event; - }); - } else { - // bot doesn't have an associated user id - this.botUserIdMap[event.bot_id] = false; - event.user = {}; - } - } else { - event.user = {}; - } - - if (event.item_user) { - event.item_user = fetched.shift(); - } - return event; - }).then(fetchedEvent => { - // hand the event off to the eventHandler - try { - this.eventHandler(fetchedEvent); - } - catch (error) { - this.robot.logger.error(error, `An error occurred while processing an event: from Slack Client ${error.message}.`); - } - }).catch(error => { - return this.robot.logger.error(error, `Incoming 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} results.members - */ - -if (SlackClient.CONVERSATION_CACHE_TTL_MS === NaN) { - throw new Error('HUBOT_SLACK_CONVERSATION_CACHE_TTL_MS must be a number. It could not be parsed.'); -} - -module.exports = SlackClient; diff --git a/src/message.js b/src/message.js index 3014710a..ca820288 100644 --- a/src/message.js +++ b/src/message.js @@ -1,5 +1,5 @@ -const {Message, TextMessage} = require.main.require("hubot/es2015.js"); -const SlackClient = require("./client"); +const { Message, TextMessage, TopicMessage } = require.main.require("hubot/es2015.js"); +const SlackClient = require("./bot.js"); const SlackMention = require("./mention"); class ReactionMessage extends Message { @@ -114,10 +114,9 @@ class SlackTextMessage extends TextMessage { * @param {SlackClient} client - a client that can be used to get more data needed to build the text * @param {function} cb - callback for the result */ - buildText(client, cb) { + async buildText(client) { // base text let text = (this.rawMessage.text != null) ? this.rawMessage.text : ""; - // flatten any attachments into text if (this.rawMessage.attachments) { const attachment_text = this.rawMessage.attachments.map(a => a.fallback).join("\n"); @@ -128,19 +127,18 @@ class SlackTextMessage extends TextMessage { const mentionFormatting = this.replaceLinks(client, text); // Fetch conversation info const fetchingConversationInfo = client.fetchConversation(this._channel_id); - return Promise.all([mentionFormatting, fetchingConversationInfo]) - .then(results => { - const [ replacedText, conversationInfo ] = results; - text = replacedText; - text = text.replace(/</g, "<"); - text = text.replace(/>/g, ">"); - text = text.replace(/&/g, "&"); - this.text = text; - return cb(); - }).catch(error => { - client.robot.logger.error(error, `An error occurred while building text: ${error.message}`); - return cb(error); - }); + let results = []; + try { + results = await Promise.all([mentionFormatting, fetchingConversationInfo]); + const [ replacedText, conversationInfo ] = results; + text = replacedText; + text = text.replace(/</g, "<"); + text = text.replace(/>/g, ">"); + text = text.replace(/&/g, "&"); + } catch (e) { + client.robot.logger.error(e, `An error occurred while building text: ${e.message}`); + } + return text; } /** @@ -271,22 +269,14 @@ class SlackTextMessage extends TextMessage { * @param {SlackClient} client - client used to fetch more data * @param {function} cb - callback to return the result */ - static makeSlackTextMessage(user, text, rawText, rawMessage, channel_id, robot_name, robot_alias, client, cb) { - const message = new SlackTextMessage(user, text, rawText, rawMessage, channel_id, robot_name, robot_alias); - - // creates a completion function that consistently calls the callback after this function has returned - const done = message => setImmediate(() => cb(null, message)); - - if ((message.text == null)) { - return message.buildText(client, function(error) { - if (error) { - return cb(error); - } - return done(message); - }); - } else { - return done(message); + static async makeSlackTextMessage(user, text, rawText, rawMessage, channel_id, robot_name, robot_alias, client) { + if(rawMessage?.subtype) { + return new TopicMessage(user, rawMessage.text, rawMessage.event_ts) } + const message = new SlackTextMessage(user, text, rawText, rawMessage, channel_id, robot_name, robot_alias); + if (message.text !== null) return message; + message.text = await message.buildText(client); + return message; } } diff --git a/test/bot.js b/test/bot.js index 030ff7b4..b6099317 100644 --- a/test/bot.js +++ b/test/bot.js @@ -1,7 +1,7 @@ const {describe, it, beforeEach, before, after} = require('node:test'); const assert = require('node:assert/strict'); const Module = require('module'); -const SlackBot = require('../src/bot.js'); +const SlackBot = require('../src/bot.js').SlackBot; const hookModuleToReturnMockFromRequire = (module, mock) => { const originalRequire = Module.prototype.require; @@ -208,23 +208,6 @@ describe('Send Messages', function() { }); }); -describe('Client sending message', function() { - let stubs, client; - beforeEach(function() { - ({stubs, client} = require('./stubs.js')()); - }); - - it('Should append as_user = true', function() { - client.send({room: stubs.channel.id}, {text: 'foo', user: stubs.user, channel: stubs.channel.id}); - assert.ok(stubs._opts.as_user); - }); - - it('Should append as_user = true only as a default', function() { - client.send({room: stubs.channel.id}, {text: 'foo', user: stubs.user, channel: stubs.channel.id, as_user: false}); - assert.deepEqual(stubs._opts.as_user, true); - }); -}); - describe('Reply to Messages', function() { let stubs, slackbot; beforeEach(function() { @@ -290,16 +273,18 @@ describe('Setting the channel topic', function() { ({stubs, slackbot} = require('./stubs.js')()); }); - it('Should set the topic in channels', function(t, done) { + it('Should set the topic in channels', async () => { + let wasCalled = false; stubs.receiveMock.onTopic = function(topic) { assert.deepEqual(topic, 'channel'); - done(); + wasCalled = true; }; - slackbot.setTopic({room: stubs.channel.id}, 'channel'); + await slackbot.setTopic({room: stubs.channel.id}, 'channel'); + assert.deepEqual(wasCalled, true); }); - it('Should NOT set the topic in DMs', function() { - slackbot.setTopic({room: 'D1232'}, 'DM'); + it('Should NOT set the topic in DMs', async () => { + await slackbot.setTopic({room: 'D1232'}, 'DM'); assert.equal(stubs._topic, undefined); }); }); diff --git a/test/client.js b/test/client.js deleted file mode 100644 index 31140d5d..00000000 --- a/test/client.js +++ /dev/null @@ -1,446 +0,0 @@ - -const { describe, it, beforeEach } = require('node:test'); -const assert = require('node:assert/strict'); -const Module = require('module'); - -const hookModuleToReturnMockFromRequire = (module, mock) => { - const originalRequire = Module.prototype.require; - Module.prototype.require = function() { - if (arguments[0] === module) { - return mock; - } - return originalRequire.apply(this, arguments); - }; -}; - -const hubotSlackMock = require('../index.js'); -hookModuleToReturnMockFromRequire('hubot-slack', hubotSlackMock); - -const SocketModeClient = require('@slack/socket-mode').SocketModeClient; -const WebClient = require('@slack/web-api').WebClient; - - -describe('Init', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('Should initialize with an SocketModeClient client', function() { - assert.ok(client.socket instanceof SocketModeClient) - }); - - it('Should initialize with a Web client', function() { - assert.ok(client.web instanceof WebClient); - assert.deepEqual(client.web.token, 'xoxb-faketoken'); - }); - -}); - -describe('connect()', () => { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('Should be able to connect', async () => { - await client.connect(); - assert.ok(client.socket.connected); - }); -}); - -describe('onEvent()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('should not need to be set', function() { - client.socket.emit('message', { fake: 'message' }); - assert.ok(true); - }); - it('should emit pre-processed messages to the callback', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(message.user?.real_name, stubs.user.real_name); - assert.deepEqual(message.channel, stubs.channel.id); - done(); - }); - // the shape of the following object is a raw message event: https://api.slack.com/events/message - client.socket.emit('message', { - type: 'message', - text: 'blah', - user: stubs.user.id, - channel: stubs.channel.id, - ts: stubs.event_timestamp - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); - it('should successfully convert bot users', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(message.user.id, stubs.user.id); - assert.deepEqual(message.channel, stubs.channel.id); - done(); - }); - // the shape of the following object is a raw message event: https://api.slack.com/events/message - client.socket.emit('message', { - type: 'message', - bot_id: 'B123', - channel: stubs.channel.id, - text: 'blah' - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); - - it('should handle undefined bot users', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(message.channel, stubs.channel.id); - done(); - }); - client.socket.emit('message', { - type: 'message', - bot_id: 'B789', - channel: stubs.channel.id, - text: 'blah' - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); - - it('should handle undefined users as envisioned', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(message.channel, stubs.channel.id); - done(); - }); - client.socket.emit('message', { - type: 'message', - user: undefined, - channel: stubs.channel.id, - text: 'eat more veggies' - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); - - it('should update bot id to user representation map', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(client.botUserIdMap[stubs.bot.id].id, stubs.user.id); - done(); - }); - - // the shape of the following object is a raw message event: https://api.slack.com/events/message - client.socket.emit('message', { - type: 'message', - bot_id: stubs.bot.id, - channel: stubs.channel.id, - text: 'blah' - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); - it('should use user representation for bot id in map', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(message.user.id, stubs.user.id); - done(); - }); - - client.botUserIdMap[stubs.bot.id] = stubs.user; - // the shape of the following object is a raw message event: https://api.slack.com/events/message - client.socket.emit('message', { - type: 'message', - bot_id: stubs.bot.id, - channel: stubs.channel.id, - text: 'blah' - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); - it('should log an error when expanded info cannot be fetched using the Web API', function(t, done) { - client.onEvent(message => { - console.log('throwing error', message) - throw new Error('A message was emitted'); - }); - client.socket.emit('message', { - type: 'message', - user: 'NOT A USER', - channel: stubs.channel.id, - text: 'blah', - ts: '1355517523.000005' - }); - // wait for the next tick to check the logs so that the error can be thrown - // and caught by the test framework. process.nextTick executes the function - // after everything in the current call stack has been executed. - process.nextTick(() => { - if (stubs.robot.logger.logs) { - console.log(JSON.stringify(stubs.robot.logger.logs)) - assert.deepEqual(stubs.robot.logger.logs?.error?.length, 1); - } - done(); - }); - }); - - it('should use user instead of bot_id', function(t, done) { - client.onEvent(message => { - assert.ok(message); - assert.deepEqual(message.user.id, stubs.user.id); - done(); - }); - - client.botUserIdMap[stubs.bot.id] = stubs.userperiod; - client.socket.emit('message', { - type: 'message', - bot_id: stubs.bot.id, - user: stubs.user.id, - channel: stubs.channel.id, - text: 'blah' - }); - assert.deepEqual(stubs.robot.logger.logs.error, undefined); - }); -}); - -describe('setTopic()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it("Should set the topic in a channel", async function() { - await client.setTopic(stubs.channel.id, 'iAmTopic'); - assert.deepEqual(stubs._topic, 'iAmTopic'); - }); - it("should not set the topic in a DM", async function() { - await client.setTopic(stubs.DM.id, 'iAmTopic'); - assert.deepEqual(stubs['_topic'], undefined); - }); - it("should not set the topic in a MPIM", async function() { - await client.setTopic(stubs.group.id, 'iAmTopic'); - assert.deepEqual(stubs['_topic'], undefined); - // NOTE: no good way to assert that debug log was output - }); - it("should log an error if the setTopic web API method fails", async function() { - await client.setTopic('NOT A CONVERSATION', 'iAmTopic'); - assert.deepEqual(stubs['_topic'], undefined); - if (stubs.robot.logger.logs != null) { - assert.deepEqual(stubs.robot.logger.logs.error.length, 1); - } - }); -}); - -describe('send()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('Should send a plain string message to room', function() { - client.send({room: 'room1'}, {text: 'Message'}); - assert.deepEqual(stubs._msg, 'Message'); - assert.deepEqual(stubs._room, 'room1'); - }); - - it('Should send an object message to room', function() { - client.send({room: 'room2'}, {text: 'textMessage'}); - assert.deepEqual(stubs._msg, 'textMessage'); - assert.deepEqual(stubs._room, 'room2'); - }); - - it('Should be able to send a DM to a user object', function() { - client.send({ room: stubs.user.id }, {text: 'DM Message'}); - assert.deepEqual(stubs._dmmsg, 'DM Message'); - assert.deepEqual(stubs._room, stubs.user.id); - }); - - it('should not send a message to a user without an ID', function() { - client.send({ name: 'my_crufty_username'}, {text: "don't program with usernames"}); - assert.deepEqual(stubs._sendCount, 0); - }); - - it('should log an error when chat.postMessage fails (plain string)', function(t, done) { - client.send({ room: stubs.channelWillFailChatPost}, {text: "Message"}); - assert.deepEqual(stubs._sendCount, 0); - return setImmediate(( () => { - if (stubs.robot.logger.logs != null) { - assert.deepEqual(stubs.robot.logger.logs.error.length, 1); - } - done(); - } - ), 0); - }); - - it('should log an error when chat.postMessage fails (object)', function(t, done) { - client.send({ room: stubs.channelWillFailChatPost}, {text: "textMessage"}); - assert.deepEqual(stubs._sendCount, 0); - return setImmediate(( () => { - if (stubs.robot.logger.logs != null) { - assert.deepEqual(stubs.robot.logger.logs.error.length, 1); - } - done(); - } - ), 0); - }); -}); - -describe('loadUsers()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('should make successive calls to users.list', function() { - return client.loadUsers((err, result) => { - if (stubs != null) { - assert.deepEqual(stubs._listCount, 2); - } - assert.deepEqual(result.members.length, 4); - }); - }); - it('should handle errors', function() { - stubs._listError = true; - return client.loadUsers((err, result) => { - assert.ok(err instanceof Error); - }); - }); -}); - -describe('Users data', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('Should add a user data', function() { - client.updateUserInBrain(stubs.user); - const user = slackbot.robot.brain.data.users[stubs.user.id]; - assert.deepEqual(user.id, stubs.user.id); - assert.deepEqual(user.name, stubs.user.name); - assert.deepEqual(user.real_name, stubs.user.real_name); - assert.deepEqual(user.email_address, stubs.user.profile.email); - assert.deepEqual(user.slack.misc, stubs.user.misc); - }); - - it('Should add a user data (user with no profile)', function() { - client.updateUserInBrain(stubs.usernoprofile); - const user = slackbot.robot.brain.data.users[stubs.usernoprofile.id]; - assert.deepEqual(user.id, stubs.usernoprofile.id); - assert.deepEqual(user.name, stubs.usernoprofile.name); - assert.deepEqual(user.real_name, stubs.usernoprofile.real_name); - assert.deepEqual(user.slack.misc, stubs.usernoprofile.misc); - assert.ok(user.email_address == undefined); - }); - - it('Should add a user data (user with no email in profile)', function() { - client.updateUserInBrain(stubs.usernoemail); - - const user = slackbot.robot.brain.data.users[stubs.usernoemail.id]; - assert.deepEqual(user.id, stubs.usernoemail.id); - assert.deepEqual(user.name, stubs.usernoemail.name); - assert.deepEqual(user.real_name, stubs.usernoemail.real_name); - assert.deepEqual(user.slack.misc, stubs.usernoemail.misc); - assert.ok(user.email_address == undefined); - }); - - it('Should modify a user data', function() { - client.updateUserInBrain(stubs.user); - - let user = slackbot.robot.brain.data.users[stubs.user.id]; - assert.deepEqual(user.id, stubs.user.id); - assert.deepEqual(user.name, stubs.user.name); - assert.deepEqual(user.real_name, stubs.user.real_name); - assert.deepEqual(user.email_address, stubs.user.profile.email); - assert.deepEqual(user.slack.misc, stubs.user.misc); - - const user_change_event = { - type: 'user_change', - user: { - id: stubs.user.id, - name: 'modified_name', - real_name: stubs.user.real_name, - profile: { - email: stubs.user.profile.email - } - } - }; - - client.updateUserInBrain(user_change_event); - - user = slackbot.robot.brain.data.users[stubs.user.id]; - assert.deepEqual(user.id, stubs.user.id); - assert.deepEqual(user.name, user_change_event.user.name); - assert.deepEqual(user.real_name, stubs.user.real_name); - assert.deepEqual(user.email_address, stubs.user.profile.email); - assert.deepEqual(user.slack.misc, undefined); - assert.deepEqual(user.slack.client, undefined); - }); -}); - -describe('fetchBotUser()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('should return user representation from map', async function() { - const { - user - } = stubs; - client.botUserIdMap[stubs.bot.id] = user; - const res = await client.fetchBotUser(stubs.bot.id) - assert.deepEqual(res.id, user.id); - }); - - it('should return constant data if id is slackbots id', async function() { - const user = stubs.slack_bot; - const res = await client.fetchBotUser(stubs.slack_bot.id) - assert.deepEqual(res.id, user.id); - assert.deepEqual(res.user_id, user.user_id); - }); -}); - -describe('fetchUser()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('should return user representation from brain', async function() { - const { - user - } = stubs; - client.updateUserInBrain(user); - const res = await client.fetchUser(user.id) - assert.deepEqual(res.id, user.id); - }); - - it('Should sync interacting users when syncing disabled', async function() { - slackbot.options.disableUserSync = true; - slackbot.run(); - - const res = await client.fetchUser(stubs.user.id) - assert.ok(Object.keys(slackbot.robot.brain.data.users).includes('U123')); - }); -}); - -describe('fetchConversation()', function() { - let stubs, slackbox, client; - beforeEach(function() { - ({ stubs, slackbot, client } = require('./stubs.js')()); - }); - it('Should remove expired conversation info', async function() { - const { - channel - } = stubs; - client.channelData[channel.id] = { - channel: {id: 'C123', name: 'foo'}, - updated: stubs.expired_timestamp - }; - const res = await client.fetchConversation(channel.id) - assert.deepEqual(res.name, channel.name); - assert.ok(Object.keys(client.channelData).includes('C123')); - assert.deepEqual(client.channelData['C123'].channel.name, channel.name); - }); - it('Should return conversation info if not expired', async function() { - const { - channel - } = stubs; - client.channelData[channel.id] = { - channel: {id: 'C123', name: 'foo'}, - updated: Date.now() - }; - const res = await client.fetchConversation(channel.id) - assert.deepEqual(res.id, channel.id); - assert.ok(Object.keys(client.channelData).includes('C123')); - assert.deepEqual(client.channelData['C123'].channel.name, 'foo'); - }); -}); diff --git a/test/message.js b/test/message.js index 66d426f1..1736a5c6 100644 --- a/test/message.js +++ b/test/message.js @@ -1,7 +1,8 @@ const { describe, it, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const Module = require('module'); - +const SlackTextMessage = require('../src/message.js').SlackTextMessage; +const TopicMessage = require('hubot/src/message.js').TopicMessage; const hookModuleToReturnMockFromRequire = (module, mock) => { const originalRequire = Module.prototype.require; Module.prototype.require = function() { @@ -35,294 +36,262 @@ describe('buildText()', function() { ({ stubs, client, slacktextmessage, slacktextmessage_invalid_conversation } = require('./stubs.js')()); }); - it('Should decode entities', function(t, done) { - const message = slacktextmessage; + it('Should decode entities', async () => { + let message = slacktextmessage; message.rawMessage.text = 'foo > & < >&<'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo > & < >&<'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo > & < >&<'); }); - it('Should remove formatting around links', function(t, done) { - const message = slacktextmessage; + it('Should remove formatting around links', async () => { + let message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo http://www.example.com bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo http://www.example.com bar'); }); - it('Should remove formatting around links', function(t, done) { - const message = slacktextmessage; + it('Should remove formatting around links', async () => { + let message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo https://www.example.com bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo https://www.example.com bar'); }); - it('Should remove formatting around links', function(t, done) { + it('Should remove formatting around links', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo skype:echo123?call bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo skype:echo123?call bar'); }); - it('Should remove formatting around links with a label', function(t, done) { + it('Should remove formatting around links with a label', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo label (https://www.example.com) bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo label (https://www.example.com) bar'); }); - it('Should remove formatting around links with a substring label', function(t, done) { + it('Should remove formatting around links with a substring label', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo https://www.example.com bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo https://www.example.com bar'); }); - it('Should remove formatting around links with a label containing entities', function(t, done) { + it('Should remove formatting around links with a label containing entities', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo label > & < (https://www.example.com) bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo label > & < (https://www.example.com) bar'); }); - it('Should remove formatting around links', function(t, done) { + it('Should remove formatting around links', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo name@example.com bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo name@example.com bar'); }); - it('Should remove formatting around links with an email label', function(t, done) { + it('Should remove formatting around links with an email label', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo name@example.com bar'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo name@example.com bar'); }); - it('Should handle empty text with attachments', function(t, done) { + it('Should handle empty text with attachments', async () => { const message = slacktextmessage; message.rawMessage.text = undefined; message.rawMessage.attachments = [ { fallback: 'first' }, ]; - message.buildText(client, () => { - assert.deepEqual(message.text, '\nfirst'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, '\nfirst'); }); - it('Should handle an empty set of attachments', function(t, done) { + it('Should handle an empty set of attachments', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo'; message.rawMessage.attachments = []; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo'); }); - it('Should change multiple links at once', function(t, done) { + it('Should change multiple links at once', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo <@U123|label> bar <#C123> '; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo @label bar #general @channel label (https://www.example.com)'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo @label bar #general @channel label (https://www.example.com)'); }); - it('Should populate mentions with simple SlackMention object', function(t, done) { + it('Should populate mentions with simple SlackMention object', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo <@U123> bar'; - message.buildText(client, function() { - assert.deepEqual(message.mentions.length, 1); - assert.deepEqual(message.mentions[0].type, 'user'); - assert.deepEqual(message.mentions[0].id, 'U123'); - assert.deepEqual((message.mentions[0] instanceof SlackMention), true); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.mentions.length, 1); + assert.deepEqual(message.mentions[0].type, 'user'); + assert.deepEqual(message.mentions[0].id, 'U123'); + assert.deepEqual((message.mentions[0] instanceof SlackMention), true); }); - it('Should populate mentions with simple SlackMention object with label', function(t, done) { + it('Should populate mentions with simple SlackMention object with label', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo <@U123|label> bar'; - message.buildText(client, function() { - assert.deepEqual(message.mentions.length, 1); - assert.deepEqual(message.mentions[0].type, 'user'); - assert.deepEqual(message.mentions[0].id, 'U123'); - assert.deepEqual(message.mentions[0].info, undefined); - assert.deepEqual((message.mentions[0] instanceof SlackMention), true); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.mentions.length, 1); + assert.deepEqual(message.mentions[0].type, 'user'); + assert.deepEqual(message.mentions[0].id, 'U123'); + assert.deepEqual(message.mentions[0].info, undefined); + assert.deepEqual((message.mentions[0] instanceof SlackMention), true); }); - it('Should populate mentions with multiple SlackMention objects', function(t, done) { + it('Should populate mentions with multiple SlackMention objects', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo <@U123> bar <#C123> baz <@U123|label> qux'; - message.buildText(client, function() { - assert.deepEqual(message.mentions.length, 3); - assert.deepEqual((message.mentions[0] instanceof SlackMention), true); - assert.deepEqual((message.mentions[1] instanceof SlackMention), true); - assert.deepEqual((message.mentions[2] instanceof SlackMention), true); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.mentions.length, 3); + assert.deepEqual((message.mentions[0] instanceof SlackMention), true); + assert.deepEqual((message.mentions[1] instanceof SlackMention), true); + assert.deepEqual((message.mentions[2] instanceof SlackMention), true); }); - it('Should populate mentions with simple SlackMention object if user in brain', function(t, done) { + it('Should populate mentions with simple SlackMention object if user in brain', async () => { client.updateUserInBrain(stubs.user); const message = slacktextmessage; message.rawMessage.text = 'foo <@U123> bar'; - message.buildText(client, function() { - assert.deepEqual(message.mentions.length, 1); - assert.deepEqual(message.mentions[0].type, 'user'); - assert.deepEqual(message.mentions[0].id, 'U123'); - assert.deepEqual((message.mentions[0] instanceof SlackMention), true); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.mentions.length, 1); + assert.deepEqual(message.mentions[0].type, 'user'); + assert.deepEqual(message.mentions[0].id, 'U123'); + assert.deepEqual((message.mentions[0] instanceof SlackMention), true); }); - it('Should add conversation to cache', function(t, done) { + it('Should add conversation to cache', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; - message.buildText(client, function() { - assert.deepEqual(message.text, 'foo bar'); - assert.ok(Object.keys(client.channelData).includes('C123')); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo bar'); + assert.ok(Object.keys(client.channelData).includes('C123')); }); - it('Should not modify conversation if it is not expired', function(t, done) { + it('Should not modify conversation if it is not expired', async () => { const message = slacktextmessage; client.channelData[stubs.channel.id] = { channel: {id: stubs.channel.id, name: 'baz'}, updated: Date.now() }; message.rawMessage.text = 'foo bar'; - message.buildText(client, function() { - assert.deepEqual(message.text, 'foo bar'); - assert.ok(Object.keys(client.channelData).includes('C123')); - assert.deepEqual(client.channelData['C123'].channel.name, 'baz'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo bar'); + assert.ok(Object.keys(client.channelData).includes('C123')); + assert.deepEqual(client.channelData['C123'].channel.name, 'baz'); }); - it('Should handle conversation errors', function(t, done) { + it('Should handle conversation errors', async () => { const message = slacktextmessage_invalid_conversation; message.rawMessage.text = 'foo bar'; - message.buildText(client, () => { - client.robot.logger.logs != null ? assert.deepEqual(client.robot.logger.logs.error.length, 1) : undefined; - done(); - }); + message.text = await message.buildText(client); + client.robot.logger.logs != null ? assert.deepEqual(client.robot.logger.logs.error.length, 1) : undefined; }); - it('Should flatten attachments', function(t, done) { + it('Should flatten attachments', async () => { const message = slacktextmessage; message.rawMessage.text = 'foo bar'; message.rawMessage.attachments = [ { fallback: 'first' }, { fallback: 'second' } ]; - message.buildText(client, () => { - assert.deepEqual(message.text, 'foo bar\nfirst\nsecond'); - done(); - }); + message.text = await message.buildText(client); + assert.deepEqual(message.text, 'foo bar\nfirst\nsecond'); }); + + it('Should make a TopicMessage if subtype is channel_topic', async () => { + const message = await SlackTextMessage.makeSlackTextMessage({}, null, null, { + subtype: 'channel_topic', + topic: 'foo' + }, 'test', 'test-bot', null, null); + assert.deepEqual(message instanceof TopicMessage, true); + }) }); -describe('replaceLinks()', function() { +describe('replaceLinks()', () => { let stubs, client, slacktextmessage, slacktextmessage_invalid_conversation; - beforeEach(function() { + beforeEach(() => { ({ stubs, client, slacktextmessage, slacktextmessage_invalid_conversation } = require('./stubs.js')()); }); - it('Should change <@U123> links to @name', async function() { + it('Should change <@U123> links to @name', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <@U123> bar'); assert.deepEqual(text, 'foo @name bar'); }); - it('Should change <@U123|label> links to @label', async function() { + it('Should change <@U123|label> links to @label', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <@U123|label> bar'); assert.deepEqual(text, 'foo @label bar'); }); - it('Should handle invalid User ID gracefully', async function() { + it('Should handle invalid User ID gracefully', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <@U555> bar'); assert.deepEqual(text, 'foo <@U555> bar'); }); - it('Should handle empty User API response', async function() { + it('Should handle empty User API response', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <@U789> bar'); assert.deepEqual(text, 'foo <@U789> bar'); }); - it('Should change <#C123> links to #general', async function() { + it('Should change <#C123> links to #general', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <#C123> bar'); assert.deepEqual(text, 'foo #general bar'); }); - it('Should change <#C123|label> links to #label', async function() { + it('Should change <#C123|label> links to #label', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <#C123|label> bar'); assert.deepEqual(text, 'foo #label bar'); }); - it('Should handle invalid Conversation ID gracefully', async function() { + it('Should handle invalid Conversation ID gracefully', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <#C555> bar'); assert.deepEqual(text, 'foo <#C555> bar'); }); - it('Should handle empty Conversation API response', async function() { + it('Should handle empty Conversation API response', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo <#C789> bar'); assert.deepEqual(text, 'foo <#C789> bar'); }); - it('Should change links to @everyone', async function() { + it('Should change links to @everyone', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo @everyone bar'); }); - it('Should change links to @channel', async function() { + it('Should change links to @channel', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo @channel bar'); }); - it('Should change links to @group', async function() { + it('Should change links to @group', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo @group bar'); }); - it('Should change links to @here', async function() { + it('Should change links to @here', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo @here bar'); }); - it('Should change links to @subteam', async function() { + it('Should change links to @subteam', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo @subteam bar'); }); - it('Should change links to hello', async function() { + it('Should change links to hello', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo hello bar'); }); - it('Should leave links as-is when no label is provided', async function() { + it('Should leave links as-is when no label is provided', async () => { const text = await slacktextmessage.replaceLinks(client, 'foo bar'); assert.deepEqual(text, 'foo bar'); }); diff --git a/test/stubs.js b/test/stubs.js index 5e49cce1..fc25d760 100644 --- a/test/stubs.js +++ b/test/stubs.js @@ -7,10 +7,10 @@ */ // Setup stubs used by the other tests -const SlackBot = require('../src/bot'); -const SlackClient = require('../src/client'); +const SlackBot = require('../src/bot.js').SlackBot; +const SlackClient = require('../src/bot.js').SlackClient; const {EventEmitter} = require('events'); -const { SlackTextMessage } = require('../src/message'); +const { SlackTextMessage } = require('../src/message.js'); // Use Hubot's brain in our stubs const {Brain, Robot} = require('hubot'); @@ -239,7 +239,7 @@ module.exports = function() { } }; stubs.conversationsMock = { - setTopic: ({channel, topic}) => { + setTopic: async ({channel, topic}) => { stubs._topic = topic; if (stubs.receiveMock.onTopic != null) { stubs.receiveMock.onTopic(stubs._topic);