UNPKG

@wearekadence/botbuilder-adapter-slack

Version:
708 lines 32.9 kB
"use strict"; /** * @module botbuilder-adapter-slack */ /** * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SlackAdapter = void 0; const botbuilder_1 = require("botbuilder"); const web_api_1 = require("@slack/web-api"); const botworker_1 = require("./botworker"); const crypto = __importStar(require("crypto")); const debug_1 = __importDefault(require("debug")); const debug = (0, debug_1.default)('botkit:slack'); /** * Connect [Botkit](https://www.npmjs.com/package/botkit) or [BotBuilder](https://www.npmjs.com/package/botbuilder) to Slack. */ class SlackAdapter extends botbuilder_1.BotAdapter { options; slack; identity; /** * Name used by Botkit plugin loader * @ignore */ name = 'Slack Adapter'; /** * Object containing one or more Botkit middlewares to bind automatically. * @ignore */ middlewares; /** * A customized BotWorker object that exposes additional utility methods. * @ignore */ botkit_worker = botworker_1.SlackBotWorker; /** * Create a Slack adapter. * * The SlackAdapter can be used in 2 modes: * * As an "[internal integration](https://api.slack.com/internal-integrations) connected to a single Slack workspace * * As a "[Slack app](https://api.slack.com/slack-apps) that uses oauth to connect to multiple workspaces and can be submitted to the Slack app. * * [Read here for more information about all the ways to configure the SlackAdapter &rarr;](../../botbuilder-adapter-slack/readme.md). * * Use with Botkit: *```javascript * const adapter = new SlackAdapter({ * clientSigningSecret: process.env.CLIENT_SIGNING_SECRET, * botToken: process.env.BOT_TOKEN * }); * const controller = new Botkit({ * adapter: adapter, * // ... other configuration options * }); * ``` * * Use with BotBuilder: *```javascript * const adapter = new SlackAdapter({ * clientSigningSecret: process.env.CLIENT_SIGNING_SECRET, * botToken: process.env.BOT_TOKEN * }); * // set up restify... * const server = restify.createServer(); * server.use(restify.plugins.bodyParser()); * server.post('/api/messages', (req, res) => { * adapter.processActivity(req, res, async(context) => { * // do your bot logic here! * }); * }); * ``` * * Use in "Slack app" multi-team mode: * ```javascript * const adapter = new SlackAdapter({ * clientSigningSecret: process.env.CLIENT_SIGNING_SECRET, * clientId: process.env.CLIENT_ID, // oauth client id * clientSecret: process.env.CLIENT_SECRET, // oauth client secret * scopes: ['bot'], // oauth scopes requested * oauthVersion: 'v1', * redirectUri: process.env.REDIRECT_URI, // url to redirect post login defaults to `https://<mydomain>/install/auth` * getTokenForTeam: async(team_id) => Promise<string>, // function that returns a token based on team id * getBotUserByTeam: async(team_id) => Promise<string>, // function that returns a bot's user id based on team id * }); * ``` * * @param options An object containing API credentials, a webhook verification token and other options */ constructor(options) { super(); this.options = options; /* * Check for security options. If these are not set, malicious actors can * spoof messages from Slack. * These will be required in upcoming versions of Botkit. */ if (!this.options.verificationToken && !this.options.clientSigningSecret) { const warning = [ '', '****************************************************************************************', '* WARNING: Your bot is operating without recommended security mechanisms in place. *', '* Initialize your adapter with a clientSigningSecret parameter to enable *', '* verification that all incoming webhooks originate with Slack: *', '* *', '* var adapter = new SlackAdapter({clientSigningSecret: <my secret from slack>}); *', '* *', '****************************************************************************************', '>> Slack docs: https://api.slack.com/docs/verifying-requests-from-slack', '' ]; console.warn(warning.join('\n')); if (!this.options.enable_incomplete) { throw new Error('Required: include a verificationToken or clientSigningSecret to verify incoming Events API webhooks'); } } if (this.options.botToken) { this.slack = new web_api_1.WebClient(this.options.botToken); this.slack.auth.test().then((raw_identity) => { const identity = raw_identity; debug('** Slack adapter running in single team mode.'); debug('** My Slack identity: ', identity.user, 'on team', identity.team); this.identity = { user_id: identity.user_id }; }).catch((err) => { // This is a fatal error! Invalid credentials have been provided and the bot can't start. console.error(err); process.exit(1); }); } else if (!this.options.getTokenForTeam || !this.options.getBotUserByTeam) { // This is a fatal error. No way to get a token to interact with the Slack API. console.error('Missing Slack API credentials! Provide either a botToken or a getTokenForTeam() and getBotUserByTeam function as part of the SlackAdapter options.'); if (!this.options.enable_incomplete) { throw new Error('Incomplete Slack configuration'); } } else if (!this.options.clientId || !this.options.clientSecret || !this.options.scopes || !this.options.redirectUri) { // This is a fatal error. Need info to connet to Slack via oauth console.error('Missing Slack API credentials! Provide clientId, clientSecret, scopes and redirectUri as part of the SlackAdapter options.'); if (!this.options.enable_incomplete) { throw new Error('Incomplete Slack configuration'); } } else { debug('** Slack adapter running in multi-team mode.'); } if (!this.options.oauthVersion) { this.options.oauthVersion = 'v1'; } this.options.oauthVersion = this.options.oauthVersion.toLowerCase(); if (this.options.enable_incomplete) { const warning = [ '', '****************************************************************************************', '* WARNING: Your adapter may be running with an incomplete/unsafe configuration. *', '* - Ensure all required configuration options are present *', '* - Disable the "enable_incomplete" option! *', '****************************************************************************************', '' ]; console.warn(warning.join('\n')); } this.middlewares = { spawn: [ async (bot, next) => { // make the Slack API available to all bot instances. bot.api = await this.getAPI(bot.getConfig('activity')).catch((err) => { debug('An error occurred while trying to get API creds for team', err); return next(new Error('Could not spawn a Slack API instance')); }); next(); } ] }; } /** * Get a Slack API client with the correct credentials based on the team identified in the incoming activity. * This is used by many internal functions to get access to the Slack API, and is exposed as `bot.api` on any bot worker instances. * @param activity An incoming message activity */ async getAPI(activity) { // use activity.channelData.team.id (the slack team id) and get the appropriate token using getTokenForTeam if (this.slack) { return this.slack; } else { // @ts-ignore if (activity.conversation.team) { // @ts-ignore const token = await this.options.getTokenForTeam(activity.conversation.team); if (!token) { throw new Error('Missing credentials for team.'); } return new web_api_1.WebClient(token); } else { // No API can be created, this is debug('Unable to create API based on activity: ', activity); } } } /** * Get the bot user id associated with the team on which an incoming activity originated. This is used internally by the SlackMessageTypeMiddleware to identify direct_mention and mention events. * In single-team mode, this will pull the information from the Slack API at launch. * In multi-team mode, this will use the `getBotUserByTeam` method passed to the constructor to pull the information from a developer-defined source. * @param activity An incoming message activity */ async getBotUserByTeam(activity) { if (this.identity) { return this.identity.user_id; } else { // @ts-ignore if (activity.conversation.team) { // @ts-ignore const user_id = await this.options.getBotUserByTeam(activity.conversation.team); if (!user_id) { throw new Error('Missing credentials for team.'); } return user_id; } else { debug('Could not find bot user id based on activity: ', activity); } } } /** * Get the oauth link for this bot, based on the clientId and scopes passed in to the constructor. * * An example using Botkit's internal webserver to configure the /install route: * * ```javascript * controller.webserver.get('/install', (req, res) => { * res.redirect(controller.adapter.getInstallLink()); * }); * ``` * * @returns A url pointing to the first step in Slack's oauth flow. */ getInstallLink() { let redirect = ''; if (this.options.clientId && this.options.scopes) { if (this.options.oauthVersion === 'v2') { redirect = 'https://slack.com/oauth/v2/authorize?client_id=' + this.options.clientId + '&scope=' + this.options.scopes.join(','); } else { redirect = 'https://slack.com/oauth/authorize?client_id=' + this.options.clientId + '&scope=' + this.options.scopes.join(','); } if (this.options.redirectUri) { redirect += '&redirect_uri=' + encodeURIComponent(this.options.redirectUri); } return redirect; } else { throw new Error('getInstallLink() cannot be called without clientId and scopes in adapter options'); } } /** * Validates an oauth v2 code sent by Slack during the install process. * * An example using Botkit's internal webserver to configure the /install/auth route: * * ```javascript * controller.webserver.get('/install/auth', async (req, res) => { * try { * const results = await controller.adapter.validateOauthCode(req.query.code); * // make sure to capture the token and bot user id by team id... * const team_id = results.team.id; * const token = results.access_token; * const bot_user = results.bot_user_id; * // store these values in a way they'll be retrievable with getBotUserByTeam and getTokenForTeam * } catch (err) { * console.error('OAUTH ERROR:', err); * res.status(401); * res.send(err.message); * } * }); * ``` * @param code the value found in `req.query.code` as part of Slack's response to the oauth flow. */ async validateOauthCode(code) { const slack = new web_api_1.WebClient(); const details = { code, client_id: this.options.clientId, client_secret: this.options.clientSecret, redirect_uri: this.options.redirectUri }; let results = {}; if (this.options.oauthVersion === 'v2') { results = await slack.oauth.v2.access(details); } else { results = await slack.oauth.access(details); } if (results.ok) { return results; } else { throw new Error(results.error); } } /** * Formats a BotBuilder activity into an outgoing Slack message. * @param activity A BotBuilder Activity object * @returns a Slack message object with {text, attachments, channel, thread_ts} as well as any fields found in activity.channelData */ activityToSlack(activity) { const channelId = activity.conversation.id; // @ts-ignore ignore this non-standard field const thread_ts = activity.conversation.thread_ts; const message = { ts: activity.id, text: activity.text, attachments: activity.attachments, channel: channelId, thread_ts }; // if channelData is specified, overwrite any fields in message object if (activity.channelData) { Object.keys(activity.channelData).forEach(function (key) { message[key] = activity.channelData[key]; }); } // should this message be sent as an ephemeral message if (message.ephemeral) { message.user = activity.recipient.id; } if (message.icon_url || message.icon_emoji || message.username) { message.as_user = false; } // as_user flag is deprecated on v2 if (message.as_user === false && this.options.oauthVersion === 'v2') { delete message.as_user; } return message; } /** * Standard BotBuilder adapter method to send a message from the bot to the messaging API. * [BotBuilder reference docs](https://docs.microsoft.com/en-us/javascript/api/botbuilder-core/botadapter?view=botbuilder-ts-latest#sendactivities). * @param context A TurnContext representing the current incoming message and environment. * @param activities An array of outgoing activities to be sent back to the messaging API. */ async sendActivities(context, activities) { const responses = []; for (let a = 0; a < activities.length; a++) { const activity = activities[a]; if (activity.type === botbuilder_1.ActivityTypes.Message) { const message = this.activityToSlack(activity); try { const slack = await this.getAPI(context.activity); let result = null; if (message.ephemeral) { debug('chat.postEphemeral:', message); result = await slack.chat.postEphemeral(message); } else { debug('chat.postMessage:', message); result = await slack.chat.postMessage(message); } if (result.ok === true) { responses.push({ id: result.ts, activityId: result.ts, conversation: { id: result.channel } }); } else { console.error('Error sending activity to API:', result); } } catch (err) { console.error('Error sending activity to API:', err); } } else { // If there are ever any non-message type events that need to be sent, do it here. debug('Unknown message type encountered in sendActivities: ', activity.type); } } return responses; } /** * Standard BotBuilder adapter method to update a previous message with new content. * [BotBuilder reference docs](https://docs.microsoft.com/en-us/javascript/api/botbuilder-core/botadapter?view=botbuilder-ts-latest#updateactivity). * @param context A TurnContext representing the current incoming message and environment. * @param activity The updated activity in the form `{id: <id of activity to update>, ...}` */ async updateActivity(context, activity) { if (activity.id && activity.conversation) { try { const message = this.activityToSlack(activity); const slack = await this.getAPI(activity); const results = await slack.chat.update(message); if (!results.ok) { console.error('Error updating activity on Slack:', results); } } catch (err) { console.error('Error updating activity on Slack:', err); } } else { throw new Error('Cannot update activity: activity is missing id'); } } /** * Standard BotBuilder adapter method to delete a previous message. * [BotBuilder reference docs](https://docs.microsoft.com/en-us/javascript/api/botbuilder-core/botadapter?view=botbuilder-ts-latest#deleteactivity). * @param context A TurnContext representing the current incoming message and environment. * @param reference An object in the form `{activityId: <id of message to delete>, conversation: { id: <id of slack channel>}}` */ async deleteActivity(context, reference) { if (reference.activityId && reference.conversation) { try { const slack = await this.getAPI(context.activity); const results = await slack.chat.delete({ ts: reference.activityId, channel: reference.conversation.id }); if (!results.ok) { console.error('Error deleting activity:', results); } } catch (err) { console.error('Error deleting activity', err); throw err; } } else { throw new Error('Cannot delete activity: reference is missing activityId'); } } /** * Standard BotBuilder adapter method for continuing an existing conversation based on a conversation reference. * [BotBuilder reference docs](https://docs.microsoft.com/en-us/javascript/api/botbuilder-core/botadapter?view=botbuilder-ts-latest#continueconversation) * @param reference A conversation reference to be applied to future messages. * @param logic A bot logic function that will perform continuing action in the form `async(context) => { ... }` */ async continueConversation(reference, logic) { const request = botbuilder_1.TurnContext.applyConversationReference({ type: 'event', name: 'continueConversation' }, reference, true); const context = new botbuilder_1.TurnContext(this, request); return this.runMiddleware(context, logic); } /** * Verify the signature of an incoming webhook request as originating from Slack. * @param req A request object from Restify or Express * @param res A response object from Restify or Express * @returns If signature is valid, returns true. Otherwise, sends a 401 error status via http response and then returns false. */ async verifySignature(req, res) { // is this an verified request from slack? if (this.options.clientSigningSecret && req.rawBody) { const timestamp = req.header('X-Slack-Request-Timestamp'); const body = req.rawBody; const signature = [ 'v0', timestamp, body // request body ]; const basestring = signature.join(':'); const hash = 'v0=' + crypto.createHmac('sha256', this.options.clientSigningSecret) .update(basestring) .digest('hex'); const retrievedSignature = req.header('X-Slack-Signature'); // Compare the hash of the computed signature with the retrieved signature with a secure hmac compare function const validSignature = () => { const slackSigBuffer = Buffer.from(retrievedSignature); const compSigBuffer = Buffer.from(hash); return crypto.timingSafeEqual(slackSigBuffer, compSigBuffer); }; // replace direct compare with the hmac result if (!validSignature()) { debug('Signature verification failed, Ignoring message'); res.status(401); res.end(); return false; } } return true; } /** * Accept an incoming webhook request and convert it into a TurnContext which can be processed by the bot's logic. * @param req A request object from Restify or Express * @param res A response object from Restify or Express * @param logic A bot logic function in the form `async(context) => { ... }` */ async processActivity(req, res, logic) { // Create an Activity based on the incoming message from Slack. // There are a few different types of event that Slack might send. let event = req.body; if (event.type === 'url_verification') { res.status(200); res.header('Content-Type: text/plain'); res.send(event.challenge); return; } if (!await this.verifySignature(req, res)) { return; } if (event.payload) { // handle interactive_message callbacks and block_actions event = JSON.parse(event.payload); if (this.options.verificationToken && event.token !== this.options.verificationToken) { console.error('Rejected due to mismatched verificationToken:', event); res.status(403); res.end(); } else { const activity = { timestamp: new Date(), channelId: 'slack', conversation: { id: event.channel ? event.channel.id : event.team.id, thread_ts: event.thread_ts, team: event.team.id }, from: { id: event.user.id ?? event.bot_id }, recipient: { id: null }, channelData: event, type: botbuilder_1.ActivityTypes.Event, text: null }; // If this is a message originating from a block_action or button click, we'll mark it as a message // so it gets processed in BotkitConversations if ((event.type === 'block_actions' || event.type === 'interactive_message') && event.actions) { activity.type = botbuilder_1.ActivityTypes.Message; switch (event.actions[0].type) { case 'button': activity.text = event.actions[0].value; break; case 'static_select': case 'external_select': case 'overflow': activity.text = event.actions[0].selected_option.value; break; case 'users_select': activity.text = event.actions[0].selected_user; break; case 'conversations_select': activity.text = event.actions[0].selected_conversation; break; case 'channels_select': activity.text = event.actions[0].selected_channel; break; case 'datepicker': activity.text = event.actions[0].selected_date; break; default: activity.text = event.actions[0].type; } } // @ts-ignore this complains because of extra fields in conversation activity.recipient.id = await this.getBotUserByTeam(activity); // create a conversation reference // @ts-ignore const context = new botbuilder_1.TurnContext(this, activity); context.turnState.set('httpStatus', 200); await this.runMiddleware(context, logic); // send http response back res.status(context.turnState.get('httpStatus')); if (context.turnState.get('httpBody')) { res.send(context.turnState.get('httpBody')); } else { res.end(); } } } else if (event.type === 'event_callback') { // this is an event api post if (this.options.verificationToken && event.token !== this.options.verificationToken) { console.error('Rejected due to mismatched verificationToken:', event); res.status(403); res.end(); } else { const activity = { id: event.event.ts ? event.event.ts : event.event.event_ts, timestamp: new Date(), channelId: 'slack', conversation: { id: event.event.channel ? event.event.channel : event.event.channel_id, thread_ts: event.event.thread_ts }, from: { id: event.event.bot_id ? event.event.bot_id : event.event.user }, recipient: { id: null }, channelData: event.event, text: null, type: botbuilder_1.ActivityTypes.Event }; if (!activity.conversation.id) { // uhoh! this doesn't have a conversation id because it might have occurred outside a channel. // or be in reference to an item in a channel. if (event.event.item && event.event.item.channel) { activity.conversation.id = event.event.item.channel; } else { activity.conversation.id = event.team_id; } } // Copy over the authed_users activity.channelData.authed_users = event.authed_users; // @ts-ignore this complains because of extra fields in conversation activity.recipient.id = await this.getBotUserByTeam(activity); // Normalize the location of the team id activity.channelData.team = event.team_id; // add the team id to the conversation record // @ts-ignore -- Tell Typescript to ignore this overload activity.conversation.team = activity.channelData.team; // If this is conclusively a message originating from a user, we'll mark it as such if (event.event.type === 'message' && !event.event.subtype) { activity.type = botbuilder_1.ActivityTypes.Message; activity.text = event.event.text; } if (!activity.conversation.id) { console.error('Got Slack activity without a conversation id', event); return; } // create a conversation reference // @ts-ignore const context = new botbuilder_1.TurnContext(this, activity); context.turnState.set('httpStatus', 200); await this.runMiddleware(context, logic); // send http response back res.status(context.turnState.get('httpStatus')); if (context.turnState.get('httpBody')) { res.send(context.turnState.get('httpBody')); } else { res.end(); } } } else if (event.command) { if (this.options.verificationToken && event.token !== this.options.verificationToken) { console.error('Rejected due to mismatched verificationToken:', event); res.status(403); res.end(); } else { // this is a slash command const activity = { id: event.trigger_id, timestamp: new Date(), channelId: 'slack', conversation: { id: event.channel_id }, from: { id: event.user_id }, recipient: { id: null }, channelData: event, text: event.text, type: botbuilder_1.ActivityTypes.Event }; activity.recipient.id = await this.getBotUserByTeam(activity); // Normalize the location of the team id activity.channelData.team = event.team_id; // add the team id to the conversation record // @ts-ignore -- Tell Typescript to ignore this overload activity.conversation.team = activity.channelData.team; activity.channelData.botkitEventType = 'slash_command'; // create a conversation reference // @ts-ignore const context = new botbuilder_1.TurnContext(this, activity); context.turnState.set('httpStatus', 200); await this.runMiddleware(context, logic); // send http response back res.status(context.turnState.get('httpStatus')); if (context.turnState.get('httpBody')) { res.send(context.turnState.get('httpBody')); } else { res.end(); } } } else { console.error('Unknown Slack event type: ', event); } } } exports.SlackAdapter = SlackAdapter; //# sourceMappingURL=slack_adapter.js.map