diff --git a/docs/_basic/socket_mode.md b/docs/_basic/socket_mode.md new file mode 100644 index 000000000..58a3e496f --- /dev/null +++ b/docs/_basic/socket_mode.md @@ -0,0 +1,64 @@ +--- +title: Using Socket Mode +lang: en +slug: socket-mode +order: 16 +--- + +
+[Socket Mode](https://api.slack.com/socket-mode) allows your app to connect and receive data from Slack via a WebSocket connection. To handle the connection, Bolt for JavaScript includes a `SocketModeReceiver` (in `@slack/bolt@3.0.0` and higher). Before using Socket Mode, be sure to enable it within your app configuration. + +To use the `SocketModeReceiver`, just pass in `socketMode:true` and `appToken:YOUR_APP_TOKEN` when initializing `App`. You can get your App Level Token in your app configuration under the **Basic Information** section. +
+ +```javascript +const { App } = require('@slack/bolt'); + +const app = new App({ + token: process.env.BOT_TOKEN + socketMode: true, + appToken: process.env.APP_TOKEN, +}); + +(async () => { + await app.start(); + console.log('⚡️ Bolt app started'); +})(); +``` + +
+ +

Custom SocketMode Receiver

+
+ +
+You can define a custom `SocketModeReceiver` by importing it from `@slack/bolt`. + +
+ +```javascript +const { App, SocketModeReceiver } = require('@slack/bolt'); + +const socketModeReceiver = new SocketModeReceiver({ + appToken: process.env.APP_TOKEN, + + // enable the following if you want to use OAuth + // clientId: process.env.CLIENT_ID, + // clientSecret: process.env.CLIENT_SECRET, + // stateSecret: 'my-state-secret', + // scopes: ['channels:read', 'chat:write', 'app_mentions:read', 'channels:manage', 'commands'], +}); + +const app = new App({ + receiver: socketModeReceiver, + // disable token line below if using OAuth + token: process.env.BOT_TOKEN +}); + +(async () => { + await app.start(); + console.log('⚡️ Bolt app started'); +})(); +``` + +
diff --git a/examples/socket-mode/README.md b/examples/socket-mode/README.md new file mode 100644 index 000000000..693104802 --- /dev/null +++ b/examples/socket-mode/README.md @@ -0,0 +1,70 @@ +# Bolt for JavaScript Socket Mode Test App + +This is a quick example app to test [Socket Mode](https://api.slack.com/socket-mode) with Bolt for JavaScript. + +If using OAuth, Slack requires a public URL where it can send requests. In this guide, we'll be using [`ngrok`](https://ngrok.com/download). Checkout [this guide](https://api.slack.com/tutorials/tunneling-with-ngrok) for setting it up. OAuth installation is only needed for public distribution. For internal apps, we recommend installing via your app configuration. + +Before we get started, make sure you have a development workspace where you have permissions to install apps. If you don’t have one setup, go ahead and [create one](https://slack.com/create). You also need to [create a new app](https://api.slack.com/apps?new_app=1) if you haven’t already. You will need to enable Socket Mode and generate an App Level Token. + +## Install Dependencies + +``` +npm install +``` + +## Install app to workspace + +In your [app configuration](https://api.slack.com/apps), go to **OAuth & Permissions** and add the `channels:read`, `app_mentions:read`, `commands`, and `chat:write` permissions. Click **Install App** to install the app to your workspace and generate a bot token. + +Next, navigate to the **Socket Mode** section and toggle the **Enable Socket Mode** button to start receiving events over a WebSocket connection. + +Next, click on **Basic Information** and generate a `App Level Token` with the `connections:write` scope. + +Then navigate to **App Home**. Under **Show tabs**, toggle the **Home tab** option. + +Lastly, in **Events Subscription**, click **Subscribe to bot events** and add `app_home_opened`, `app_mentioned`, and `message.channels`. + +## Setup Environment Variables + +This app requires you setup a few environment variables. You can find these values in your [app configuration](https://api.slack.com/apps). + +``` +// can get this from OAuth & Permission page in app configuration +export BOT_TOKEN=YOUR_SLACK_BOT_TOKEN +// can generate the app level token from basic information page in app configuration +export APP_TOKEN=YOUR_SLACK_APP_TOKEN + +// if using OAuth, also export the following +export CLIENT_ID=YOUR_SLACK_CLIENT_ID +export CLIENT_SECRET=YOUR_SLACK_CLIENT_SECRET +``` + +## Run the App + +Start the app with the following command: + +``` +npm start +``` + +### Running with OAuth + +Only implement OAuth if you plan to distribute your application across multiple workspaces. Uncomment out the OAuth specific comments in the code. If you are on dev instance, you will have to uncomment out those options as well. + +Start `ngrok` so we can access the app on an external network and create a redirect URL for OAuth. + +``` +ngrok http 3000 +``` + +This output should include a forwarding address for `http` and `https` (we'll use the `https` one). It should look something like the following: + +``` +Forwarding https://3cb89939.ngrok.io -> http://localhost:3000 +``` + +Then navigate to **OAuth & Permissions** in your app configuration and click **Add a Redirect URL**. The redirect URL should be set to your `ngrok` forwarding address with the `slack/oauth_redirect` path appended. ex: + +``` +https://3cb89939.ngrok.io/slack/oauth_redirect +``` diff --git a/examples/socket-mode/app.js b/examples/socket-mode/app.js new file mode 100644 index 000000000..2439d7382 --- /dev/null +++ b/examples/socket-mode/app.js @@ -0,0 +1,185 @@ +const { App, LogLevel, SocketModeReceiver } = require('@slack/bolt'); + +const clientOptions = { + // enable this for dev instance + // slackApiUrl: 'https://dev.slack.com/api/' +}; + +// const socketModeReceiver = new SocketModeReceiver({ +// appToken: process.env.APP_TOKEN, +// installerOptions: { +// clientOptions, +// // use the following when running against a dev instance and using OAuth +// // authorizationUrl: 'https://dev.slack.com/oauth/v2/authorize', +// }, + +// // enable the following if you want to use OAuth +// // clientId: process.env.CLIENT_ID, +// // clientSecret: process.env.CLIENT_SECRET, +// // stateSecret: 'my-state-secret', +// // scopes: ['channels:read', 'chat:write', 'app_mentions:read', 'channels:manage', 'commands'], + +// logLevel: LogLevel.DEBUG, +// }); + +const app = new App({ + // receiver: socketModeReceiver, + token: process.env.BOT_TOKEN, //disable this if enabling OAuth in socketModeReceiver + // logLevel: LogLevel.DEBUG, + clientOptions, + appToken: process.env.APP_TOKEN, + socketMode: true, +}); + +(async () => { + await app.start(); + console.log('⚡️ Bolt app started'); +})(); + +// Publish a App Home +app.event('app_home_opened', async ({ event, client }) => { + await client.views.publish({ + user_id: event.user, + view: { + "type":"home", + "blocks":[ + { + "type": "section", + "block_id": "section678", + "text": { + "type": "mrkdwn", + "text": "App Home Published" + }, + } + ] + }, + }); +}); + +// Message Shortcut example +app.shortcut('launch_msg_shortcut', async ({ shortcut, body, ack, context, client }) => { + await ack(); + console.log(shortcut); +}); + +// Global Shortcut example +// setup global shortcut in App config with `launch_shortcut` as callback id +// add `commands` scope +app.shortcut('launch_shortcut', async ({ shortcut, body, ack, context, client }) => { + try { + // Acknowledge shortcut request + await ack(); + + // Call the views.open method using one of the built-in WebClients + const result = await client.views.open({ + trigger_id: shortcut.trigger_id, + view: { + type: "modal", + title: { + type: "plain_text", + text: "My App" + }, + close: { + type: "plain_text", + text: "Close" + }, + blocks: [ + { + type: "section", + text: { + type: "mrkdwn", + text: "About the simplest modal you could conceive of :smile:\n\nMaybe or ." + } + }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: "Psssst this modal was designed using " + } + ] + } + ] + } + }); + } + catch (error) { + console.error(error); + } +}); + + +// subscribe to 'app_mention' event in your App config +// need app_mentions:read and chat:write scopes +app.event('app_mention', async ({ event, context, client, say }) => { + try { + await say({"blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": `Thanks for the mention <@${event.user}>! Click my fancy button` + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Button", + "emoji": true + }, + "value": "click_me_123", + "action_id": "first_button" + } + } + ]}); + } + catch (error) { + console.error(error); + } +}); + +// subscribe to `message.channels` event in your App Config +// need channels:read scope +app.message('hello', async ({ message, say }) => { + // say() sends a message to the channel where the event was triggered + // no need to directly use 'chat.postMessage', no need to include token + await say({"blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": `Thanks for the mention <@${message.user}>! Click my fancy button` + }, + "accessory": { + "type": "button", + "text": { + "type": "plain_text", + "text": "Button", + "emoji": true + }, + "value": "click_me_123", + "action_id": "first_button" + } + } + ]}); +}); + +// Listen and respond to button click +app.action('first_button', async({action, ack, say, context}) => { + console.log('button clicked'); + console.log(action); + // acknowledge the request right away + await ack(); + await say('Thanks for clicking the fancy button'); +}); + +// Listen to slash command +// need to add commands permission +// create slash command in App Config +app.command('/socketslash', async ({ command, ack, say }) => { + // Acknowledge command request + await ack(); + + await say(`${command.text}`); +}); diff --git a/examples/socket-mode/package.json b/examples/socket-mode/package.json new file mode 100644 index 000000000..d4730de7e --- /dev/null +++ b/examples/socket-mode/package.json @@ -0,0 +1,15 @@ +{ + "name": "bolt-socket-mode-example", + "version": "1.0.0", + "description": "Example app using socket mode", + "main": "index.js", + "scripts": { + "start": "node app.js", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Slack Technologies, Inc.", + "license": "MIT", + "dependencies": { + "@slack/bolt": "feat-socket-mode" + } +} diff --git a/package.json b/package.json index f9c3a1eb0..25a5cbe15 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "dependencies": { "@slack/logger": "^2.0.0", "@slack/oauth": "^1.4.0", + "@slack/socket-mode": "feat-socket-mode", "@slack/types": "^1.9.0", "@slack/web-api": "^5.14.0", "@types/express": "^4.16.1", diff --git a/src/App.ts b/src/App.ts index 4ec0254e3..e39de9f68 100644 --- a/src/App.ts +++ b/src/App.ts @@ -5,6 +5,7 @@ import util from 'util'; import { WebClient, ChatPostMessageArguments, addAppMetadata, WebClientOptions } from '@slack/web-api'; import { Logger, LogLevel, ConsoleLogger } from '@slack/logger'; import axios, { AxiosInstance } from 'axios'; +import SocketModeReceiver from './SocketModeReceiver'; import ExpressReceiver, { ExpressReceiverOptions } from './ExpressReceiver'; import { ignoreSelf as ignoreSelfMiddleware, @@ -67,6 +68,7 @@ export interface AppOptions { clientTls?: Pick; convoStore?: ConversationStore | false; token?: AuthorizeResult['botToken']; // either token or authorize + appToken?: string; // TODO should this be included in AuthorizeResult botId?: AuthorizeResult['botId']; // only used when authorize is not defined, shortcut for fetching botUserId?: AuthorizeResult['botUserId']; // only used when authorize is not defined, shortcut for fetching authorize?: Authorize; // either token or authorize @@ -75,6 +77,8 @@ export interface AppOptions { logLevel?: LogLevel; ignoreSelf?: boolean; clientOptions?: Pick; + socketMode?: boolean; + developerMode?: boolean; } export { LogLevel, Logger } from '@slack/logger'; @@ -158,6 +162,9 @@ export default class App { /** Logger */ private logger: Logger; + /** Log Level */ + private logLevel: LogLevel; + /** Authorize */ private authorize!: Authorize; @@ -173,6 +180,10 @@ export default class App { private installerOptions: ExpressReceiverOptions['installerOptions']; + private socketMode: boolean; + + private developerMode: boolean; + constructor({ signingSecret = undefined, endpoints = undefined, @@ -181,6 +192,7 @@ export default class App { receiver = undefined, convoStore = undefined, token = undefined, + appToken = undefined, botId = undefined, botUserId = undefined, authorize = undefined, @@ -195,7 +207,23 @@ export default class App { installationStore = undefined, scopes = undefined, installerOptions = undefined, + socketMode = undefined, + developerMode = false, }: AppOptions = {}) { + // this.logLevel = logLevel; + this.developerMode = developerMode; + if (developerMode) { + // Set logLevel to Debug in Developer Mode if one wasn't passed in + this.logLevel = logLevel ?? LogLevel.DEBUG; + // Set SocketMode to true if one wasn't passed in + this.socketMode = socketMode ?? true; + } else { + // If devs aren't using Developer Mode or Socket Mode, set it to false + this.socketMode = socketMode ?? false; + // Set logLevel to Info if one wasn't passed in + this.logLevel = logLevel ?? LogLevel.INFO; + } + if (typeof logger === 'undefined') { // Initialize with the default logger const consoleLogger = new ConsoleLogger(); @@ -204,8 +232,8 @@ export default class App { } else { this.logger = logger; } - if (typeof logLevel !== 'undefined' && this.logger.getLevel() !== logLevel) { - this.logger.setLevel(logLevel); + if (typeof this.logLevel !== 'undefined' && this.logger.getLevel() !== this.logLevel) { + this.logger.setLevel(this.logLevel); } this.errorHandler = defaultErrorHandler(this.logger); this.clientOptions = { @@ -233,9 +261,44 @@ export default class App { ...installerOptions, }; + if ( + this.developerMode && + this.installerOptions && + (typeof this.installerOptions.callbackOptions === 'undefined' || + (typeof this.installerOptions.callbackOptions !== 'undefined' && + typeof this.installerOptions.callbackOptions.failure === 'undefined')) + ) { + // add a custom failure callback for Developer Mode in case they are using OAuth + this.logger.debug('adding Developer Mode custom OAuth failure handler'); + this.installerOptions.callbackOptions = { + failure: (error, _installOptions, _req, res) => { + this.logger.debug(error); + res.writeHead(500, { 'Content-Type': 'text/html' }); + res.end(`

OAuth failed!

${error}
`); + }, + }; + } + // Check for required arguments of ExpressReceiver if (receiver !== undefined) { this.receiver = receiver; + } else if (this.socketMode) { + if (appToken === undefined) { + throw new AppInitializationError('You must provide an appToken when using Socket Mode'); + } + this.logger.debug('Initializing SocketModeReceiver'); + // Create default SocketModeReceiver + this.receiver = new SocketModeReceiver({ + appToken, + clientId, + clientSecret, + stateSecret, + installationStore, + scopes, + logger, + logLevel: this.logLevel, + installerOptions: this.installerOptions, + }); } else if (signingSecret === undefined) { // No custom receiver throw new AppInitializationError( @@ -243,6 +306,7 @@ export default class App { 'custom receiver.', ); } else { + this.logger.debug('Initializing ExpressReceiver'); // Create default ExpressReceiver this.receiver = new ExpressReceiver({ signingSecret, @@ -253,8 +317,9 @@ export default class App { stateSecret, installationStore, scopes, + logger, + logLevel: this.logLevel, installerOptions: this.installerOptions, - logger: this.logger, }); } @@ -519,6 +584,13 @@ export default class App { */ public async processEvent(event: ReceiverEvent): Promise { const { body, ack } = event; + + if (this.developerMode) { + // log the body of the event + // this may contain sensitive info like tokens + this.logger.debug(JSON.stringify(body)); + } + // TODO: when generating errors (such as in the say utility) it may become useful to capture the current context, // or even all of the args, as properties of the error. This would give error handling code some ability to deal // with "finally" type error situations. diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts index cca9653e2..0104eb27a 100644 --- a/src/ExpressReceiver.ts +++ b/src/ExpressReceiver.ts @@ -6,7 +6,7 @@ import rawBody from 'raw-body'; import querystring from 'querystring'; import crypto from 'crypto'; import tsscmp from 'tsscmp'; -import { Logger, ConsoleLogger } from '@slack/logger'; +import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions } from '@slack/oauth'; import App from './App'; import { ReceiverAuthenticityError, ReceiverMultipleAckError } from './errors'; @@ -17,6 +17,7 @@ import { AnyMiddlewareArgs, Receiver, ReceiverEvent } from './types'; export interface ExpressReceiverOptions { signingSecret: string; logger?: Logger; + logLevel?: LogLevel; endpoints?: | string | { @@ -65,7 +66,8 @@ export default class ExpressReceiver implements Receiver { constructor({ signingSecret = '', - logger = new ConsoleLogger(), + logger = undefined, + logLevel = LogLevel.INFO, endpoints = { events: '/slack/events' }, processBeforeResponse = false, clientId = undefined, @@ -79,15 +81,22 @@ export default class ExpressReceiver implements Receiver { // TODO: what about starting an https server instead of http? what about other options to create the server? this.server = createServer(this.app); + if (typeof logger !== 'undefined') { + this.logger = logger; + } else { + this.logger = new ConsoleLogger(); + this.logger.setLevel(logLevel); + } + const expressMiddleware: RequestHandler[] = [ - verifySignatureAndParseRawBody(logger, signingSecret), + verifySignatureAndParseRawBody(this.logger, signingSecret), respondToSslCheck, respondToUrlVerification, this.requestHandler.bind(this), ]; this.processBeforeResponse = processBeforeResponse; - this.logger = logger; + const endpointList = typeof endpoints === 'string' ? [endpoints] : Object.values(endpoints); this.router = Router(); endpointList.forEach((endpoint) => { @@ -104,6 +113,8 @@ export default class ExpressReceiver implements Receiver { clientSecret, stateSecret, installationStore, + logLevel, + logger, // pass logger that was passed in constructor, not one created locally stateStore: installerOptions.stateStore, authVersion: installerOptions.authVersion!, clientOptions: installerOptions.clientOptions, diff --git a/src/SocketModeReceiver.ts b/src/SocketModeReceiver.ts new file mode 100644 index 000000000..5f189e929 --- /dev/null +++ b/src/SocketModeReceiver.ts @@ -0,0 +1,172 @@ +/* eslint-disable @typescript-eslint/explicit-member-accessibility, @typescript-eslint/strict-boolean-expressions */ +import { SocketModeClient } from '@slack/socket-mode'; +import { createServer } from 'http'; +import { Logger, ConsoleLogger, LogLevel } from '@slack/logger'; +import { InstallProvider, CallbackOptions, InstallProviderOptions, InstallURLOptions } from '@slack/oauth'; +import App from './App'; +import { Receiver, ReceiverEvent } from './types'; + +// TODO: we throw away the key names for endpoints, so maybe we should use this interface. is it better for migrations? +// if that's the reason, let's document that with a comment. +export interface SocketModeReceiverOptions { + logger?: Logger; + logLevel?: LogLevel; + clientId?: string; + clientSecret?: string; + stateSecret?: InstallProviderOptions['stateSecret']; // required when using default stateStore + installationStore?: InstallProviderOptions['installationStore']; // default MemoryInstallationStore + scopes?: InstallURLOptions['scopes']; + installerOptions?: InstallerOptions; + appToken: string; // App Level Token +} + +// Additional Installer Options +interface InstallerOptions { + stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore + authVersion?: InstallProviderOptions['authVersion']; // default 'v2' + metadata?: InstallURLOptions['metadata']; + installPath?: string; + redirectUriPath?: string; + callbackOptions?: CallbackOptions; + userScopes?: InstallURLOptions['userScopes']; + clientOptions?: InstallProviderOptions['clientOptions']; + authorizationUrl?: InstallProviderOptions['authorizationUrl']; + port?: number; // used to create a server when doing OAuth +} + +/** + * Receives Events, Slash Commands, and Actions of a web socket connection + */ +export default class SocketModeReceiver implements Receiver { + /* Express app */ + public client: SocketModeClient; + + private app: App | undefined; + + private logger: Logger; + + public installer: InstallProvider | undefined = undefined; + + constructor({ + appToken, + logger = undefined, + logLevel = LogLevel.INFO, + clientId = undefined, + clientSecret = undefined, + stateSecret = undefined, + installationStore = undefined, + scopes = undefined, + installerOptions = {}, + }: SocketModeReceiverOptions) { + this.client = new SocketModeClient({ + appToken, + logLevel, + logger, + clientOptions: installerOptions.clientOptions, + }); + + if (typeof logger !== 'undefined') { + this.logger = logger; + } else { + this.logger = new ConsoleLogger(); + this.logger.setLevel(logLevel); + } + + if ( + clientId !== undefined && + clientSecret !== undefined && + (stateSecret !== undefined || installerOptions.stateStore !== undefined) + ) { + this.installer = new InstallProvider({ + clientId, + clientSecret, + stateSecret, + installationStore, + logLevel, + logger, // pass logger that was passed in constructor, not one created locally + stateStore: installerOptions.stateStore, + authVersion: installerOptions.authVersion!, + clientOptions: installerOptions.clientOptions, + authorizationUrl: installerOptions.authorizationUrl, + }); + } + + // Add OAuth routes to receiver + if (this.installer !== undefined) { + // use default or passed in redirect path + const redirectUriPath = + installerOptions.redirectUriPath === undefined ? '/slack/oauth_redirect' : installerOptions.redirectUriPath; + + // use default or passed in installPath + const installPath = installerOptions.installPath === undefined ? '/slack/install' : installerOptions.installPath; + + const server = createServer(async (req, res) => { + if (req.url !== undefined && req.url.startsWith(redirectUriPath)) { + // call installer.handleCallback to wrap up the install flow + await this.installer!.handleCallback(req, res, installerOptions.callbackOptions); + } else if (req.url !== undefined && req.url.startsWith(installPath)) { + try { + const url = await this.installer!.generateInstallUrl({ + metadata: installerOptions.metadata, + scopes: scopes!, + userScopes: installerOptions.userScopes, + }); + res.writeHead(200, {}); + res.end(``); + } catch (err) { + throw new Error(err); + } + } else { + this.logger.error(`Tried to reach ${req.url} which isn't a`); + // Return 404 because we don't support route + res.writeHead(404, {}); + res.end(`route ${req.url} doesn't exist!`); + } + }); + + const port = installerOptions.port === undefined ? 3000 : installerOptions.port; + this.logger.debug(`listening on port ${port} for OAuth`); + this.logger.debug(`Go to http://localhost:${port}${installPath} to initiate OAuth flow`); + // use port 3000 by default + server.listen(port); + } + + this.client.on('slack_event', async ({ ack, body }) => { + const event: ReceiverEvent = { + body, + ack, + }; + await this.app?.processEvent(event); + }); + } + + public init(app: App): void { + this.app = app; + } + + public start(): Promise { + return new Promise((resolve, reject) => { + try { + // start socket mode client + this.client.start(); + resolve(); + } catch (error) { + reject(error); + } + }); + } + + public stop(): Promise { + return new Promise((resolve, reject) => { + try { + this.client.disconnect(); + resolve(); + } catch (error) { + reject(error); + } + }); + } +} diff --git a/src/index.ts b/src/index.ts index 7951c22c4..84184cd9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,6 +17,8 @@ export { export { default as ExpressReceiver, ExpressReceiverOptions } from './ExpressReceiver'; +export { default as SocketModeReceiver } from './SocketModeReceiver'; + export * from './errors'; export * from './middleware/builtin'; export * from './types';