From 476e6a9e00ce4ac8f01439d4afcb474262120486 Mon Sep 17 00:00:00 2001 From: Fil Maj Date: Tue, 30 Apr 2024 12:54:00 +0000 Subject: [PATCH] socket-mode: Rewrite to Python(ish) Implementation (#1781) Closes #1771 --- docs/_packages/socket_mode.md | 234 +---- lint-configs/.eslintrc.js | 6 +- packages/socket-mode/.mocharc.json | 3 +- packages/socket-mode/README.md | 43 +- packages/socket-mode/package.json | 14 +- .../socket-mode/src/SlackWebSocket.spec.ts | 29 + packages/socket-mode/src/SlackWebSocket.ts | 267 ++++++ .../socket-mode/src/SocketModeClient.spec.js | 258 ------ .../socket-mode/src/SocketModeClient.spec.ts | 284 ++++++ packages/socket-mode/src/SocketModeClient.ts | 809 +++++------------- packages/socket-mode/src/SocketModeOptions.ts | 40 +- packages/socket-mode/src/logger.ts | 37 +- packages/socket-mode/test/integration.spec.js | 85 +- packages/socket-mode/tsconfig.json | 8 +- 14 files changed, 961 insertions(+), 1156 deletions(-) create mode 100644 packages/socket-mode/src/SlackWebSocket.spec.ts create mode 100644 packages/socket-mode/src/SlackWebSocket.ts delete mode 100644 packages/socket-mode/src/SocketModeClient.spec.js create mode 100644 packages/socket-mode/src/SocketModeClient.spec.ts diff --git a/docs/_packages/socket_mode.md b/docs/_packages/socket_mode.md index 3458ca778..2b1f19751 100644 --- a/docs/_packages/socket_mode.md +++ b/docs/_packages/socket_mode.md @@ -7,133 +7,11 @@ anchor_links_header: Usage # Slack Socket Mode +For baseline package documentation, please see the project's [`README`](https://github.com/slackapi/node-slack-sdk/tree/main/packages/socket-mode#readme) or the [documentation on npm](https://www.npmjs.com/package/@slack/socket-mode). -## Installation +The following contain additional examples that may be useful for consumers. -```shell -$ npm install @slack/socket-mode -``` - ---- - -### Prerequisites - -This package requires a Slack app with an **App-level token**. - -To create a new Slack app, head over to [api.slack.com/apps/new](https://api.slack.com/apps?new_app=1). - -To generate an **App Token** for your app, on your app's page on [api.slack.com/apps](https://api.slack.com/apps), under the main **Basic Information** page, scroll down to **App-Level Tokens**. Click **Generate Token and Scopes**, add a name for your app token, and click **Add Scope**. Choose `connections:write` and then click **Generate**. Copy and safely store the generated token! - -### Initialize the client - -This package is designed to support [**Socket Mode**](https://api.slack.com/socket-mode), which allows your app to receive events from Slack over a WebSocket connection. - -The package exports a `SocketModeClient` class. Your app will create an instance of the class for each workspace it communicates with. - -```javascript -const { SocketModeClient } = require('@slack/socket-mode'); - -// Read a token from the environment variables -const appToken = process.env.SLACK_APP_TOKEN; - -// Initialize -const socketModeClient = new SocketModeClient({ appToken }); -``` - -### Connect to Slack - -After your client establishes a connection, your app can send data to and receive data from Slack. Connecting is as easy as calling the `.start()` method. - -```javascript -const { SocketModeClient } = require('@slack/socket-mode'); -const appToken = process.env.SLACK_APP_TOKEN; - -const socketModeClient = new SocketModeClient({ appToken }); - -(async () => { - // Connect to Slack - await socketModeClient.start(); -})(); -``` ---- - -### Listen for an event - -Bolt apps register [listener functions](https://slack.dev/bolt-js/reference#listener-functions), which are triggered when a specific event type is received by the client. - -If you've used Node's [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter) pattern -before, then you're already familiar with how this works, since the client is an `EventEmitter`. - -The `event` argument passed to the listener is an object. Its content corresponds to the [type of -event](https://api.slack.com/events) it's registered for. - -```javascript -const { SocketModeClient } = require('@slack/socket-mode'); -const appToken = process.env.SLACK_APP_TOKEN; - -const socketModeClient = new SocketModeClient({ appToken }); - -// Attach listeners to events by type. See: https://api.slack.com/events/message -socketModeClient.on('message', async ({ event }) => { - console.log(event); -}); - -(async () => { - await socketModeClient.start(); -})(); -``` - ---- - -### Send a message - -To respond to events and send messages back into Slack, we recommend using the `@slack/web-api` package with a `bot token`. - -```javascript -const { SocketModeClient } = require('@slack/socket-mode'); -const { WebClient } = require('@slack/web-api'); - -const appToken = process.env.SLACK_APP_TOKEN; -const socketModeClient = new SocketModeClient({ appToken }); - -const { WebClient } = require('@slack/web-api'); -const webClient = new WebClient(process.env.SLACK_BOT_TOKEN); - -// Attach listeners to events by type. See: https://api.slack.com/events/message -socketModeClient.on('member_joined_channel', async ({event, body, ack}) => { - try { - // send acknowledgement back to slack over the socketMode websocket connection - // this is so slack knows you have received the event and are processing it - await ack(); - await webClient.chat.postMessage({ - blocks: [ - { - type: 'section', - text: { - type: 'mrkdwn', - text: `Welcome to the channel, <@${event.user}>. We're here to help. Let us know if you have an issue.`, - }, - accessory: { - type: 'button', - text: { - type: 'plain_text', - text: 'Get Help', - }, - value: 'get_help', - }, - }, - ], - channel: event.channel, - }); - } catch (error) { - console.log('An error occurred', error); - } -}); -``` ---- - - -### Listen for Interactivity Events +## Listen for Interactivity Events To receive interactivity events such as shorcut invocations, button clicks, and modal data submission, your listener can subscribe to "interactive" events. @@ -182,108 +60,4 @@ socketModeClient.on('slash_commands', async ({ body, ack }) => { }); ``` -When your app has multiple interactive events or slash commands, you will need to include your own routing logic. This is a good time to consider using Slack's Bolt framework, which provides an easier ways to register listeners for events and user actions. You can learn more in [Bolt's Socket Mode documentation](https://slack.dev/bolt-js/concepts#socket-mode). - ---- - -### Lifecycle events - -The client's connection to Slack has a lifecycle. This means the client can be seen as a state machine which transitions through a few states as it connects, disconnects, reconnects, and synchronizes with Slack. The client emits an event for each state it transitions to throughout its lifecycle. If your app simply needs to know whether the client is connected or not, the `.connected` boolean property can be checked. - -In the table below, the client's states are listed, which are also the names of the events you can use to observe the transition to that state. The table also includes descriptions for the states and arguments that a listener would receive. - -| Event Name | Arguments | Description | -|-----------------|-----------------|-------------| -| `connecting` | | The client is in the process of connecting to the platform. | -| `authenticated` | `(connectData)` - the response from `apps.connections.open` | The client has authenticated with the platform. This is a sub-state of `connecting`. | -| `connected` | | The client is connected to the platform and incoming events will start being emitted. | -| `ready` | | The client is ready to send outgoing messages. This is a sub-state of `connected` | -| `disconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `disconnected`. | -| `reconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `connecting`. | -| `disconnected` | `(error)` | The client is not connected to the platform. This is a steady state - no attempt to connect is occurring. The `error` argument will be `undefined` when the client initiated the disconnect (normal). | - -The client also emits events that are part of its lifecycle, but aren't states. Instead, they represent specific moments that might be helpful to your app. The following table lists these events, their description, and includes the arguments that a listener would receive. - -| Event Name | Arguments | Description | -|-----------------|-----------|-------------| -| `error` | `(error)` | An error has occurred. See [error handling](#handle-errors) for details. | -| `slack_event` | `(eventType, event)` | An incoming Slack event has been received. | -| `unable_to_socket_mode_start` | `(error)` | A problem occurred while connecting, a reconnect may or may not occur. | - ---- - -### Logging - -The `SocketModeClient` will log interesting information to the console by default. You can use the `logLevel` to decide how much or what kind of information should be output. There are a few possible log levels, which you can find in the `LogLevel` export. By default, the value is set to `LogLevel.INFO`. While you're in development, it's sometimes helpful to set this to the most verbose: `LogLevel.DEBUG`. - -```javascript -// Import LogLevel from the package -const { SocketModeClient, LogLevel } = require('@slack/socket-mode'); -const appToken = process.env.SLACK_APP_TOKEN; - -// Log level is one of the options you can set in the constructor -const socketModeClient = new SocketModeClient({ - appToken, - logLevel: LogLevel.DEBUG, -}); - -(async () => { - await socketModeClient.start(); -})(); -``` - -All the log levels, in order of most to least information are: `DEBUG`, `INFO`, `WARN`, and `ERROR`. - -
- -Sending log output somewhere besides the console - - -You can also choose to have logs sent to a custom logger using the `logger` option. A custom logger needs to implement specific methods (known as the `Logger` interface): - -| Method | Parameters | Return type | -|--------------|-------------------|-------------| -| `setLevel()` | `level: LogLevel` | `void` | -| `setName()` | `name: string` | `void` | -| `debug()` | `...msgs: any[]` | `void` | -| `info()` | `...msgs: any[]` | `void` | -| `warn()` | `...msgs: any[]` | `void` | -| `error()` | `...msgs: any[]` | `void` | - -A very simple custom logger might ignore the name and level, and write all messages to a file. - -```javascript -const { createWriteStream } = require('fs'); -const logWritable = createWriteStream('/var/my_log_file'); // Not shown: close this stream - -const socketModeClient = new SocketModeClient({ - appToken, - // Creating a logger as a literal object. It's more likely that you'd create a class. - logger: { - debug(...msgs): { logWritable.write('debug: ' + JSON.stringify(msgs)); }, - info(...msgs): { logWritable.write('info: ' + JSON.stringify(msgs)); }, - warn(...msgs): { logWritable.write('warn: ' + JSON.stringify(msgs)); }, - error(...msgs): { logWritable.write('error: ' + JSON.stringify(msgs)); }, - setLevel(): { }, - setName(): { }, - }, -}); - -(async () => { - await socketModeClient.start(); -})(); -``` -
- ---- - -## Requirements - -This package supports Node v14 and higher. It's highly recommended to use [the latest LTS version of -node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. - -## Getting Help - -If you get stuck, we're here to help. The following are the best ways to get assistance working through your issue: - - * [Issue Tracker](http://github.com/slackapi/node-slack-sdk/issues) for questions, feature requests, bug reports and general discussion related to these packages. Try searching before you create a new issue. +When your app has multiple interactive events or slash commands, you will need to include your own routing logic. This is a good time to consider using Slack's Bolt framework, which provides an easier way to register listeners for events and user actions. You can learn more in [Bolt's Socket Mode documentation](https://slack.dev/bolt-js/concepts#socket-mode). diff --git a/lint-configs/.eslintrc.js b/lint-configs/.eslintrc.js index ec4927554..c1db27fbd 100644 --- a/lint-configs/.eslintrc.js +++ b/lint-configs/.eslintrc.js @@ -225,7 +225,7 @@ module.exports = { }, { selector: 'parameter', - format: ['camelCase'], + format: ['camelCase', 'snake_case'], leadingUnderscore: 'allow', }, { @@ -271,7 +271,7 @@ module.exports = { }, }, { - files: ['src/**/*.spec.ts'], + files: ['src/**/*.spec.ts', 'src/**/*.spec.js'], rules: { // Test-specific rules // --- @@ -302,6 +302,8 @@ module.exports = { '@typescript-eslint/no-non-null-assertion': 'off', // Using ununamed functions (e.g., null logger) in tests is fine 'func-names': 'off', + // Some packages, like socket-mode, use classes, and to test constructors, it may be useful to just create a new Whatever() + 'no-new': 'off', // In tests, don't force constructing a Symbol with a descriptor, as // it's probably just for tests 'symbol-description': 'off', diff --git a/packages/socket-mode/.mocharc.json b/packages/socket-mode/.mocharc.json index d69e0b8b3..69301d444 100644 --- a/packages/socket-mode/.mocharc.json +++ b/packages/socket-mode/.mocharc.json @@ -1,4 +1,5 @@ { + "extension": ["ts"], "require": ["ts-node/register", "source-map-support/register"], "timeout": 3000 -} \ No newline at end of file +} diff --git a/packages/socket-mode/README.md b/packages/socket-mode/README.md index 2ec524a4c..07e9b2fda 100644 --- a/packages/socket-mode/README.md +++ b/packages/socket-mode/README.md @@ -1,29 +1,25 @@ # Slack Socket Mode +This package is designed to support [**Socket Mode**][socket-mode], which allows your app to receive events from Slack over a WebSocket connection. + +## Requirements + +This package supports Node v18 and higher. It's highly recommended to use [the latest LTS version of +node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. + ## Installation ```shell $ npm install @slack/socket-mode ``` - - ## Usage -These examples show the most common features of `Socket Mode`. You'll find even more extensive [documentation on the -package's website](https://slack.dev/node-slack-sdk/socket-mode) and our [api site][socket-mode]. - - - ---- - ### Initialize the client -This package is designed to support [**Socket Mode**][socket-mode], which allows your app to receive events from Slack over a WebSocket connection. - The package exports a `SocketModeClient` class. Your app will create an instance of the class for each workspace it communicates with. Creating an instance requires an [**app-level token**][app-token] from Slack. Apps connect to the **Socket Mode** API using an [**app-level token**][app-token], which starts with `xapp`. -Note: **Socket Mode** requires the `connections:write` scope. Navigate to your [app configuration](https://api.slack.com/apps) and go to the **OAuth and Permissions** section to add the scope. +Note: **Socket Mode** requires the `connections:write` scope. Navigate to your [app configuration](https://api.slack.com/apps) and go to the bottom of the **Basic Information** section to create an App token with the `connections:write` scope. ```javascript @@ -38,7 +34,7 @@ const client = new SocketModeClient({appToken}); ### Connect to Slack -After your client establishes a connection, your app can send data to and receive data from Slack. Connecting is as easy as calling the `.start()` method. +Connecting is as easy as calling the `.start()` method. ```javascript const { SocketModeClient } = require('@slack/socket-mode'); @@ -51,16 +47,13 @@ const socketModeClient = new SocketModeClient({appToken}); await socketModeClient.start(); })(); ``` ---- ### Listen for an event -Bolt apps register [listener functions](https://slack.dev/bolt-js/reference#listener-functions), which are triggered when a specific event type is received by the client. - If you've used Node's [`EventEmitter`](https://nodejs.org/api/events.html#events_class_eventemitter) pattern before, then you're already familiar with how this works, since the client is an `EventEmitter`. -The `event` argument passed to the listener is an object. Its content corresponds to the [type of +The `event` argument passed to the listener is an object. Its contents correspond to the [type of event](https://api.slack.com/events) it's registered for. ```javascript @@ -79,8 +72,6 @@ socketModeClient.on('message', (event) => { })(); ``` ---- - ### Send a message To respond to events and send messages back into Slack, we recommend using the `@slack/web-api` package with a [bot token](https://api.slack.com/authentication/token-types#bot). @@ -127,18 +118,16 @@ socketModeClient.on('member_joined_channel', async ({event, body, ack}) => { await socketModeClient.start(); })(); ``` ---- ### Lifecycle events -The client's connection to Slack has a lifecycle. This means the client can be seen as a state machine which transitions through a few states as it connects, disconnects, reconnects, and synchronizes with Slack. The client emits an event for each state it transitions to throughout its lifecycle. If your app simply needs to know whether the client is connected or not, the `.connected` boolean property can be checked. +The client's connection to Slack has a lifecycle. This means the client can be seen as a state machine which transitions through a few states as it connects, disconnects and possibly reconnects with Slack. The client emits an event for each state it transitions to throughout its lifecycle. If your app simply needs to know whether the client is connected or not, the `.connected` boolean property can be checked. In the table below, the client's states are listed, which are also the names of the events you can use to observe the transition to that state. The table also includes descriptions for the states and arguments that a listener would receive. | Event Name | Arguments | Description | |-----------------|-----------------|-------------| | `connecting` | | The client is in the process of connecting to the platform. | -| `authenticated` | `(connectData)` - the response from `apps.connections.open` | The client has authenticated with the platform. This is a sub-state of `connecting`. | | `connected` | | The client is connected to the platform and incoming events will start being emitted. | | `disconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `disconnected`. | | `reconnecting` | | The client is no longer connected to the platform and cleaning up its resources. It will soon transition to `connecting`. | @@ -148,15 +137,14 @@ The client also emits events that are part of its lifecycle, but aren't states. | Event Name | Arguments | Description | |-----------------|-----------|-------------| -| `error` | `(error)` | An error has occurred. See [error handling](#handle-errors) for details. | +| `error` | `(error)` | An error has occurred. | | `slack_event` | `(eventType, event)` | An incoming Slack event has been received. | -| `unable_to_socket_mode_start` | `(error)` | A problem occurred while connecting, a reconnect may or may not occur. | --- ### Logging -The `SocketModeClient` will log interesting information to the console by default. You can use the `logLevel` to decide how much or what kind of information should be output. There are a few possible log levels, which you can find in the `LogLevel` export. By default, the value is set to `LogLevel.INFO`. While you're in development, it's sometimes helpful to set this to the most verbose: `LogLevel.DEBUG`. +The `SocketModeClient` will log information to the console by default. You can use the `logLevel` to decide how much or what kind of information should be output. There are a few possible log levels, which you can find in the `LogLevel` export. By default, the value is set to `LogLevel.INFO`. While you're in development, it's sometimes helpful to set this to the most verbose: `LogLevel.DEBUG`. ```javascript // Import LogLevel from the package @@ -221,11 +209,6 @@ const socketModeClient = new SocketModeClient({ --- -## Requirements - -This package supports Node v18 and higher. It's highly recommended to use [the latest LTS version of -node](https://github.com/nodejs/Release#release-schedule), and the documentation is written using syntax and features from that version. - ## Getting Help If you get stuck, we're here to help. The following are the best ways to get assistance working through your issue: diff --git a/packages/socket-mode/package.json b/packages/socket-mode/package.json index d5177f667..0e77494c5 100644 --- a/packages/socket-mode/package.json +++ b/packages/socket-mode/package.json @@ -1,6 +1,6 @@ { "name": "@slack/socket-mode", - "version": "1.3.3", + "version": "2.0.0-rc.2", "description": "Official library for using the Slack Platform's Socket Mode API", "author": "Slack Technologies, LLC", "license": "MIT", @@ -18,8 +18,8 @@ "state", "connection" ], - "main": "dist/index.js", - "types": "./dist/index.d.ts", + "main": "dist/src/index.js", + "types": "./dist/src/index.d.ts", "files": [ "dist/**/*" ], @@ -40,10 +40,11 @@ "build": "npm run build:clean && tsc", "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", "lint": "eslint --ext .ts src", - "mocha": "mocha --config .mocharc.json src/*.spec.js", + "test:unit": "mocha --config .mocharc.json src/**/*.spec.ts", + "test:coverage": "nyc --reporter=text npm run test:unit", "test:integration": "mocha --config .mocharc.json test/integration.spec.js", - "test": "npm run lint && npm run build && nyc --reporter=text-summary npm run mocha && npm run test:integration", - "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" + "test": "npm run lint && npm run build && npm run test:coverage && npm run test:integration", + "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm test" }, "dependencies": { "@slack/logger": "^4", @@ -69,6 +70,7 @@ "eslint-plugin-jsdoc": "^48", "eslint-plugin-node": "^11", "mocha": "^10", + "nodemon": "^3.1.0", "nyc": "^15", "shx": "^0.3.2", "sinon": "^17", diff --git a/packages/socket-mode/src/SlackWebSocket.spec.ts b/packages/socket-mode/src/SlackWebSocket.spec.ts new file mode 100644 index 000000000..2781553c4 --- /dev/null +++ b/packages/socket-mode/src/SlackWebSocket.spec.ts @@ -0,0 +1,29 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import EventEmitter from 'eventemitter3'; +import { ConsoleLogger } from '@slack/logger'; +import logModule from './logger'; +import { SlackWebSocket } from './SlackWebSocket'; + +describe('SlackWebSocket', () => { + const sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + + describe('constructor', () => { + let logFactory: sinon.SinonStub; + beforeEach(() => { + logFactory = sandbox.stub(logModule, 'getLogger'); + }); + it('should set a default logger if none provided', () => { + new SlackWebSocket({ url: 'https://whatever.com', client: new EventEmitter(), clientPingTimeoutMS: 1, serverPingTimeoutMS: 1 }); + assert.isTrue(logFactory.called); + }); + it('should not set a default logger if one provided', () => { + new SlackWebSocket({ url: 'https://whatever.com', client: new EventEmitter(), clientPingTimeoutMS: 1, serverPingTimeoutMS: 1, logger: new ConsoleLogger() }); + assert.isFalse(logFactory.called); + }); + }); +}); diff --git a/packages/socket-mode/src/SlackWebSocket.ts b/packages/socket-mode/src/SlackWebSocket.ts new file mode 100644 index 000000000..1cb3b5cb1 --- /dev/null +++ b/packages/socket-mode/src/SlackWebSocket.ts @@ -0,0 +1,267 @@ +import { ClientOptions as WebSocketClientOptions, WebSocket } from 'ws'; +import { Agent } from 'http'; +import { EventEmitter } from 'eventemitter3'; +import log, { LogLevel, Logger } from './logger'; +import { websocketErrorWithOriginal } from './errors'; + +// Maps ws `readyState` to human readable labels https://github.com/websockets/ws/blob/HEAD/doc/ws.md#ready-state-constants +export const WS_READY_STATES = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED']; + +export interface SlackWebSocketOptions { + /** @description The Slack WebSocket URL to connect to. */ + url: string; + /** @description An instance of EventEmitter where socket-related events can be emitted to. */ + client: EventEmitter; + /** @description A LogLevel at which this class should log to. */ + logLevel?: LogLevel; + /** @description A Logger instance used to log activity to. */ + logger?: Logger, + /** @description Delay between this client sending a `ping` message, in milliseconds. */ + pingInterval?: number; + /** @description The HTTP Agent to use when establishing a WebSocket connection. */ + httpAgent?: Agent; + /** @description Whether this WebSocket should DEBUG log ping and pong events. `false` by default. */ + pingPongLoggingEnabled?: boolean; + /** + * @description How many milliseconds to wait between ping events from the server before deeming the connection + * stale. Defaults to 30,000. + */ + serverPingTimeoutMS: number; + /** + * @description How many milliseconds to wait between ping events from the server before deeming the connection + * stale. Defaults to 5,000. + */ + clientPingTimeoutMS: number; +} + +/** + * Encapsulates the Slack-specific details around establishing a WebSocket connection with the Slack backend. + * Manages the ping/pong heartbeat of the connection. + */ +export class SlackWebSocket { // python equiv: Connection + private static loggerName = 'SlackWebSocket'; + + private options: SlackWebSocketOptions; + + private logger: Logger; + + private websocket: WebSocket | null; + + /** + * The last timetamp that this WebSocket received pong from the server + */ + private lastPongReceivedTimestamp: number | undefined; + + /** + * Sentinel checking if Slack sent us a close frame or not, in order to be able + * to terminate underlying socket gracefully. + */ + private closeFrameReceived: boolean; + + /** + * Reference to the timeout timer we use to listen to pings from the server + */ + private serverPingTimeout: NodeJS.Timeout | undefined; + + /** + * Reference to the timeout timer we use to listen to pongs from the server + */ + private clientPingTimeout: NodeJS.Timeout | undefined; + + public constructor({ + url, + client, + httpAgent, + logger, + logLevel = LogLevel.INFO, + pingInterval = 5000, + pingPongLoggingEnabled = false, + serverPingTimeoutMS = 30000, + clientPingTimeoutMS = 5000, + }: SlackWebSocketOptions) { + this.options = { + url, + client, + httpAgent, + logLevel, + pingInterval, + pingPongLoggingEnabled, + serverPingTimeoutMS, + clientPingTimeoutMS, + }; + if (logger) { + this.logger = logger; + } else { + this.logger = log.getLogger(SlackWebSocket.loggerName, logLevel); + } + this.websocket = null; + this.closeFrameReceived = false; + } + + /** + * Establishes a connection with the Slack backend + */ + public connect(): void { + this.logger.debug('Initiating new WebSocket connection.'); + const options: WebSocketClientOptions = { + perMessageDeflate: false, + agent: this.options.httpAgent, + }; + + const ws = new WebSocket(this.options.url, options); + + ws.addEventListener('open', (_event) => { + this.logger.debug('WebSocket open event received (connection established)!'); + this.websocket = ws; + this.monitorPingToSlack(); + }); + ws.addEventListener('error', (event) => { + this.logger.error(`WebSocket error occurred: ${event.message}`); + this.disconnect(); + this.options.client.emit('error', websocketErrorWithOriginal(event.error)); + }); + ws.on('message', (msg, isBinary) => { + this.options.client.emit('message', msg, isBinary); + }); + ws.on('close', (code: number, data: Buffer) => { + this.logger.debug(`WebSocket close frame received (code: ${code}, reason: ${data.toString()})`); + this.closeFrameReceived = true; + this.disconnect(); + }); + + // Confirm WebSocket connection is still active + ws.on('ping', (data) => { + // Note that ws' `autoPong` option is true by default, so no need to respond to ping. + // see https://github.com/websockets/ws/blob/2aa0405a5e96754b296fef6bd6ebdfb2f11967fc/doc/ws.md#new-websocketaddress-protocols-options + if (this.options.pingPongLoggingEnabled) { + this.logger.debug(`WebSocket received ping from Slack server (data: ${data.toString()})`); + } + this.monitorPingFromSlack(); + }); + + ws.on('pong', (data) => { + if (this.options.pingPongLoggingEnabled) { + this.logger.debug(`WebSocket received pong from Slack server (data: ${data.toString()})`); + } + this.lastPongReceivedTimestamp = new Date().getTime(); + }); + } + + /** + * Disconnects the WebSocket connection with Slack, if connected. + */ + public disconnect(): void { + if (this.websocket) { + // Disconnecting a WebSocket involves a close frame handshake so we check if we've already received a close frame. + // If so, we can terminate the underlying socket connection and let the client know. + if (this.closeFrameReceived) { + this.logger.debug('Terminating WebSocket (close frame received).'); + this.terminate(); + } else { + // If we haven't received a close frame yet, then we send one to the peer, expecting to receive a close frame + // in response. + this.logger.debug('Sending close frame (status=1000).'); + this.websocket.close(1000); // send a close frame, 1000=Normal Closure + } + } else { + this.logger.debug('WebSocket already disconnected, flushing remainder.'); + this.terminate(); + } + } + + /** + * Clean up any underlying intervals, timeouts and the WebSocket. + */ + private terminate(): void { + this.websocket?.removeAllListeners(); + this.websocket?.terminate(); + this.websocket = null; + clearTimeout(this.serverPingTimeout); + clearInterval(this.clientPingTimeout); + // Emit event back to client letting it know connection has closed (in case it needs to reconnect if + // reconnecting is enabled) + this.options.client.emit('close'); + } + + /** + * Returns true if the underlying WebSocket connection is active. + */ + public isActive(): boolean { // python equiv: SocketModeClient.is_connected + if (!this.websocket) { + this.logger.debug('isActive(): websocket not instantiated!'); + return false; + } + this.logger.debug(`isActive(): websocket ready state is ${WS_READY_STATES[this.websocket.readyState]}`); + return this.websocket.readyState === 1; // readyState=1 is "OPEN" + } + + /** + * Retrieve the underlying WebSocket readyState. Returns `undefined` if the WebSocket has not been instantiated, + * otherwise will return a number between 0 and 3 inclusive representing the ready states. + */ + public get readyState(): number | undefined { + return this.websocket?.readyState; + } + + /** + * Sends data via the underlying WebSocket. Accepts an errorback argument. + */ + public send(data: string, cb: ((err: Error | undefined) => void)): void { + return this.websocket?.send(data, cb); + } + + /** + * Confirms WebSocket connection is still active; fires whenever a ping event is received + * If we don't receive another ping from the peer before the timeout, we initiate closing the connection. + */ + private monitorPingFromSlack(): void { + clearTimeout(this.serverPingTimeout); + this.serverPingTimeout = setTimeout(() => { + this.logger.warn(`A ping wasn't received from the server before the timeout of ${this.options.serverPingTimeoutMS}ms!`); + this.disconnect(); + }, this.options.serverPingTimeoutMS); + } + + /** + * Monitors WebSocket connection health; sends a ping to peer, and expects a pong within a certain timeframe. + * If that expectation is not met, we disconnect the websocket. + */ + private monitorPingToSlack(): void { + this.lastPongReceivedTimestamp = undefined; + let pingAttemptCount = 0; + clearInterval(this.clientPingTimeout); + this.clientPingTimeout = setInterval(() => { + const now = new Date().getTime(); + try { + const pingMessage = `Ping from client (${now})`; + this.websocket?.ping(pingMessage); + if (this.lastPongReceivedTimestamp === undefined) { + pingAttemptCount += 1; + } else { + // if lastPongReceivedTimestamp is defined, then the server has responded to pings at some point in the past + pingAttemptCount = 0; + } + if (this.options.pingPongLoggingEnabled) { + this.logger.debug(`Sent ping to Slack: ${pingMessage}`); + } + } catch (e) { + this.logger.error(`Failed to send ping to Slack (error: ${e})`); + this.disconnect(); + return; + } + // default invalid state is: sent > 3 pings to the server and it never responded with a pong + let isInvalid: boolean = pingAttemptCount > 3; + if (this.lastPongReceivedTimestamp !== undefined) { + // secondary invalid state is: if we did receive a pong from the server, + // has the elapsed time since the last pong exceeded the client ping timeout + const millis = now - this.lastPongReceivedTimestamp; + isInvalid = millis > this.options.clientPingTimeoutMS; + } + if (isInvalid) { + this.logger.warn(`A pong wasn't received from the server before the timeout of ${this.options.clientPingTimeoutMS}ms!`); + this.disconnect(); + } + }, this.options.clientPingTimeoutMS / 3); + this.logger.debug('Started monitoring pings to and pongs from Slack.'); + } +} diff --git a/packages/socket-mode/src/SocketModeClient.spec.js b/packages/socket-mode/src/SocketModeClient.spec.js deleted file mode 100644 index 325eced9b..000000000 --- a/packages/socket-mode/src/SocketModeClient.spec.js +++ /dev/null @@ -1,258 +0,0 @@ -const { assert } = require('chai'); -const { SocketModeClient } = require('./SocketModeClient'); - -describe('SocketModeClient', () => { - describe('public APIs', () => { - describe('isActive()', () => { - it('should return false when initialized', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - assert.isFalse(client.isActive()); - }); - }); - }); - - describe('onWebsocketMessage', () => { - beforeEach(() => { - }); - - afterEach(() => { - }); - - describe('slash_commands messages', () => { - const message = Buffer.from(JSON.stringify({ - "envelope_id": "1d3c79ab-0ffb-41f3-a080-d19e85f53649", - "payload": { - "token": "verification-token", - "team_id": "T111", - "team_domain": "xxx", - "channel_id": "C111", - "channel_name": "random", - "user_id": "U111", - "user_name": "seratch", - "command": "/hello-socket-mode", - "text": "", - "api_app_id": "A111", - "response_url": "https://hooks.slack.com/commands/T111/111/xxx", - "trigger_id": "111.222.xxx" - }, - "type": "slash_commands", - "accepts_response_payload": true - })); - - it('should be sent to both slash_commands and slack_event listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let commandListenerCalled = false; - client.on("slash_commands", async (args) => { - commandListenerCalled = args.ack !== undefined && args.body !== undefined; - }); - let slackEventListenerCalled = false; - client.on("slack_event", async (args) => { - slackEventListenerCalled = args.ack !== undefined && args.body !== undefined - && args.type === 'slash_commands' - && args.retry_num === undefined - && args.retry_reason === undefined; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.isTrue(commandListenerCalled); - assert.isTrue(slackEventListenerCalled); - }); - - it('should pass all the properties to slash_commands listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let passedEnvelopeId = undefined; - client.on("slash_commands", async ({ envelope_id }) => { - passedEnvelopeId = envelope_id; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.equal(passedEnvelopeId, '1d3c79ab-0ffb-41f3-a080-d19e85f53649'); - }); - it('should pass all the properties to slack_event listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let passedEnvelopeId = undefined; - client.on("slack_event", async ({ envelope_id }) => { - passedEnvelopeId = envelope_id; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.equal(passedEnvelopeId, '1d3c79ab-0ffb-41f3-a080-d19e85f53649'); - }); - }); - - describe('events_api messages', () => { - const message = Buffer.from(JSON.stringify({ - "envelope_id": "cda4159a-72a5-4744-aba3-4d66eb52682b", - "payload": { - "token": "verification-token", - "team_id": "T111", - "api_app_id": "A111", - "event": { - "client_msg_id": "f0582a78-72db-4feb-b2f3-1e47d66365c8", - "type": "app_mention", - "text": "<@U111>", - "user": "U222", - "ts": "1610241741.000200", - "team": "T111", - "blocks": [ - { - "type": "rich_text", - "block_id": "Sesm", - "elements": [ - { - "type": "rich_text_section", - "elements": [ - { - "type": "user", - "user_id": "U111" - } - ] - } - ] - } - ], - "channel": "C111", - "event_ts": "1610241741.000200" - }, - "type": "event_callback", - "event_id": "Ev111", - "event_time": 1610241741, - "authorizations": [ - { - "enterprise_id": null, - "team_id": "T111", - "user_id": "U222", - "is_bot": true, - "is_enterprise_install": false - } - ], - "is_ext_shared_channel": false, - "event_context": "1-app_mention-T111-C111" - }, - "type": "events_api", - "accepts_response_payload": false, - "retry_attempt": 2, - "retry_reason": "timeout" - })); - - it('should be sent to the specific and generic event listeners, and should not trip an unrelated event listener', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let otherListenerCalled = false; - client.on("app_home_opened", async () => { - otherListenerCalled = true; - }); - let eventsApiListenerCalled = false; - client.on("app_mention", async (args) => { - eventsApiListenerCalled = args.ack !== undefined - && args.body !== undefined - && args.retry_num === 2 - && args.retry_reason === 'timeout'; - }); - let slackEventListenerCalled = false; - client.on("slack_event", async (args) => { - slackEventListenerCalled = args.ack !== undefined && args.body !== undefined - && args.retry_num === 2 - && args.retry_reason === 'timeout'; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.isFalse(otherListenerCalled); - assert.isTrue(eventsApiListenerCalled); - assert.isTrue(slackEventListenerCalled); - }); - - it('should pass all the properties to app_mention listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let passedEnvelopeId = undefined; - client.on("app_mention", async ({ envelope_id }) => { - passedEnvelopeId = envelope_id; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.equal(passedEnvelopeId, 'cda4159a-72a5-4744-aba3-4d66eb52682b'); - }); - it('should pass all the properties to slack_event listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let passedEnvelopeId = undefined; - client.on("slack_event", async ({ envelope_id }) => { - passedEnvelopeId = envelope_id; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.equal(passedEnvelopeId, 'cda4159a-72a5-4744-aba3-4d66eb52682b'); - }); - }); - - describe('interactivity messages', () => { - const message = Buffer.from(JSON.stringify({ - "envelope_id": "57d6a792-4d35-4d0b-b6aa-3361493e1caf", - "payload": { - "type": "shortcut", - "token": "verification-token", - "action_ts": "1610198080.300836", - "team": { - "id": "T111", - "domain": "seratch" - }, - "user": { - "id": "U111", - "username": "seratch", - "team_id": "T111" - }, - "is_enterprise_install": false, - "enterprise": null, - "callback_id": "do-something", - "trigger_id": "111.222.xxx" - }, - "type": "interactive", - "accepts_response_payload": false - })); - - it('should be sent to the specific and generic event type listeners, and should not trip an unrelated event listener', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let otherListenerCalled = false; - client.on("slash_commands", async () => { - otherListenerCalled = true; - }); - let interactiveListenerCalled = false; - client.on("interactive", async (args) => { - interactiveListenerCalled = args.ack !== undefined && args.body !== undefined; - }); - let slackEventListenerCalled = false; - client.on("slack_event", async (args) => { - slackEventListenerCalled = args.ack !== undefined && args.body !== undefined; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.isFalse(otherListenerCalled); - assert.isTrue(interactiveListenerCalled); - assert.isTrue(slackEventListenerCalled); - }); - - it('should pass all the properties to interactive listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let passedEnvelopeId = undefined; - client.on("interactive", async ({ envelope_id }) => { - passedEnvelopeId = envelope_id; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.equal(passedEnvelopeId, '57d6a792-4d35-4d0b-b6aa-3361493e1caf'); - }); - it('should pass all the properties to slack_event listeners', async () => { - const client = new SocketModeClient({ appToken: 'xapp-' }); - let passedEnvelopeId = undefined; - client.on("slack_event", async ({ envelope_id }) => { - passedEnvelopeId = envelope_id; - }); - await client.onWebSocketMessage(message); - await sleep(30); - assert.equal(passedEnvelopeId, '57d6a792-4d35-4d0b-b6aa-3361493e1caf'); - }); - }); - }); -}); - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} diff --git a/packages/socket-mode/src/SocketModeClient.spec.ts b/packages/socket-mode/src/SocketModeClient.spec.ts new file mode 100644 index 000000000..96d67617e --- /dev/null +++ b/packages/socket-mode/src/SocketModeClient.spec.ts @@ -0,0 +1,284 @@ +import { assert } from 'chai'; +import sinon from 'sinon'; +import { ConsoleLogger } from '@slack/logger'; +import logModule from './logger'; +import { SocketModeClient } from './SocketModeClient'; + +describe('SocketModeClient', () => { + const sandbox = sinon.createSandbox(); + afterEach(() => { + sandbox.restore(); + }); + describe('constructor', () => { + let logFactory: sinon.SinonStub; + beforeEach(() => { + logFactory = sandbox.stub(logModule, 'getLogger').returns(new ConsoleLogger()); + }); + it('should throw if no app token provided', () => { + assert.throw(() => { + new SocketModeClient({ appToken: '' }); + }, 'provide an App-Level Token'); + }); + it('should allow overriding of logger', () => { + new SocketModeClient({ appToken: 'xapp-', logger: new ConsoleLogger() }); + assert.isFalse(logFactory.called); + }); + it('should create a default logger if none provided', () => { + new SocketModeClient({ appToken: 'xapp-' }); + assert.isTrue(logFactory.called); + }); + }); + describe('start()', () => { + it('should resolve once Connected state emitted'); + it('should reject once Disconnected state emitted'); + }); + describe('disconnect()', () => { + it('should resolve immediately if not yet connected'); + it('should resolve once Disconnected state emitted'); + }); + + describe('onWebSocketMessage', () => { + // While this method is protected and cannot be invoked directly, emitting the 'message' event directly invokes it + describe('slash_commands messages', () => { + const envelopeId = '1d3c79ab-0ffb-41f3-a080-d19e85f53649'; + const message = JSON.stringify({ + envelope_id: envelopeId, + payload: { + token: 'verification-token', + team_id: 'T111', + team_domain: 'xxx', + channel_id: 'C111', + channel_name: 'random', + user_id: 'U111', + user_name: 'seratch', + command: '/hello-socket-mode', + text: '', + api_app_id: 'A111', + response_url: 'https://hooks.slack.com/commands/T111/111/xxx', + trigger_id: '111.222.xxx', + }, + type: 'slash_commands', + accepts_response_payload: true, + }); + + it('should be sent to both slash_commands and slack_event listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let commandListenerCalled = false; + client.on('slash_commands', async (args) => { + commandListenerCalled = args.ack !== undefined && args.body !== undefined; + }); + let slackEventListenerCalled = false; + client.on('slack_event', async (args) => { + slackEventListenerCalled = args.ack !== undefined && args.body !== undefined && + args.type === 'slash_commands' && + args.retry_num === undefined && + args.retry_reason === undefined; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.isTrue(commandListenerCalled); + assert.isTrue(slackEventListenerCalled); + }); + + it('should pass all the properties to slash_commands listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let passedEnvelopeId = ''; + client.on('slash_commands', async ({ envelope_id }) => { + passedEnvelopeId = envelope_id; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.equal(passedEnvelopeId, envelopeId); + }); + it('should pass all the properties to slack_event listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let passedEnvelopeId = ''; + client.on('slack_event', async ({ envelope_id }) => { + passedEnvelopeId = envelope_id; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.equal(passedEnvelopeId, envelopeId); + }); + }); + + describe('events_api messages', () => { + const envelopeId = 'cda4159a-72a5-4744-aba3-4d66eb52682b'; + const message = JSON.stringify({ + envelope_id: envelopeId, + payload: { + token: 'verification-token', + team_id: 'T111', + api_app_id: 'A111', + event: { + client_msg_id: 'f0582a78-72db-4feb-b2f3-1e47d66365c8', + type: 'app_mention', + text: '<@U111>', + user: 'U222', + ts: '1610241741.000200', + team: 'T111', + blocks: [ + { + type: 'rich_text', + block_id: 'Sesm', + elements: [ + { + type: 'rich_text_section', + elements: [ + { + type: 'user', + user_id: 'U111', + }, + ], + }, + ], + }, + ], + channel: 'C111', + event_ts: '1610241741.000200', + }, + type: 'event_callback', + event_id: 'Ev111', + event_time: 1610241741, + authorizations: [ + { + enterprise_id: null, + team_id: 'T111', + user_id: 'U222', + is_bot: true, + is_enterprise_install: false, + }, + ], + is_ext_shared_channel: false, + event_context: '1-app_mention-T111-C111', + }, + type: 'events_api', + accepts_response_payload: false, + retry_attempt: 2, + retry_reason: 'timeout', + }); + + it('should be sent to the specific and generic event listeners, and should not trip an unrelated event listener', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let otherListenerCalled = false; + client.on('app_home_opened', async () => { + otherListenerCalled = true; + }); + let eventsApiListenerCalled = false; + client.on('app_mention', async (args) => { + eventsApiListenerCalled = args.ack !== undefined && + args.body !== undefined && + args.retry_num === 2 && + args.retry_reason === 'timeout'; + }); + let slackEventListenerCalled = false; + client.on('slack_event', async (args) => { + slackEventListenerCalled = args.ack !== undefined && args.body !== undefined && + args.retry_num === 2 && + args.retry_reason === 'timeout'; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.isFalse(otherListenerCalled); + assert.isTrue(eventsApiListenerCalled); + assert.isTrue(slackEventListenerCalled); + }); + + it('should pass all the properties to app_mention listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let passedEnvelopeId = ''; + client.on('app_mention', async ({ envelope_id }) => { + passedEnvelopeId = envelope_id; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.equal(passedEnvelopeId, envelopeId); + }); + it('should pass all the properties to slack_event listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let passedEnvelopeId = ''; + client.on('slack_event', async ({ envelope_id }) => { + passedEnvelopeId = envelope_id; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.equal(passedEnvelopeId, envelopeId); + }); + }); + + describe('interactivity messages', () => { + const envelopeId = '57d6a792-4d35-4d0b-b6aa-3361493e1caf'; + const message = JSON.stringify({ + envelope_id: envelopeId, + payload: { + type: 'shortcut', + token: 'verification-token', + action_ts: '1610198080.300836', + team: { + id: 'T111', + domain: 'seratch', + }, + user: { + id: 'U111', + username: 'seratch', + team_id: 'T111', + }, + is_enterprise_install: false, + enterprise: null, + callback_id: 'do-something', + trigger_id: '111.222.xxx', + }, + type: 'interactive', + accepts_response_payload: false, + }); + + it('should be sent to the specific and generic event type listeners, and should not trip an unrelated event listener', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let otherListenerCalled = false; + client.on('slash_commands', async () => { + otherListenerCalled = true; + }); + let interactiveListenerCalled = false; + client.on('interactive', async (args) => { + interactiveListenerCalled = args.ack !== undefined && args.body !== undefined; + }); + let slackEventListenerCalled = false; + client.on('slack_event', async (args) => { + slackEventListenerCalled = args.ack !== undefined && args.body !== undefined; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.isFalse(otherListenerCalled); + assert.isTrue(interactiveListenerCalled); + assert.isTrue(slackEventListenerCalled); + }); + + it('should pass all the properties to interactive listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let passedEnvelopeId = ''; + client.on('interactive', async ({ envelope_id }) => { + passedEnvelopeId = envelope_id; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.equal(passedEnvelopeId, envelopeId); + }); + it('should pass all the properties to slack_event listeners', async () => { + const client = new SocketModeClient({ appToken: 'xapp-' }); + let passedEnvelopeId = ''; + client.on('slack_event', async ({ envelope_id }) => { + passedEnvelopeId = envelope_id; + }); + client.emit('message', message, false /* isBinary */); + await sleep(30); + assert.equal(passedEnvelopeId, envelopeId); + }); + }); + }); +}); + +async function sleep(ms: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/socket-mode/src/SocketModeClient.ts b/packages/socket-mode/src/SocketModeClient.ts index a136ca8e6..870fdacd2 100644 --- a/packages/socket-mode/src/SocketModeClient.ts +++ b/packages/socket-mode/src/SocketModeClient.ts @@ -1,6 +1,5 @@ import { EventEmitter } from 'eventemitter3'; import WebSocket from 'ws'; -import Finity, { Context, StateMachine, Configuration } from 'finity'; import { WebClient, AppsConnectionsOpenResponse, @@ -9,7 +8,7 @@ import { addAppMetadata, WebClientOptions, } from '@slack/web-api'; -import { LogLevel, Logger, getLogger } from './logger'; +import log, { LogLevel, Logger } from './logger'; import { websocketErrorWithOriginal, sendWhileDisconnectedError, @@ -17,76 +16,84 @@ import { } from './errors'; import { UnrecoverableSocketModeStartError } from './UnrecoverableSocketModeStartError'; import { SocketModeOptions } from './SocketModeOptions'; +import { SlackWebSocket, WS_READY_STATES } from './SlackWebSocket'; +import packageJson from '../package.json'; -const packageJson = require('../package.json'); // eslint-disable-line import/no-commonjs, @typescript-eslint/no-var-requires - -// These enum values are used only in the state machine +// Lifecycle events as described in the README enum State { Connecting = 'connecting', Connected = 'connected', Reconnecting = 'reconnecting', Disconnecting = 'disconnecting', Disconnected = 'disconnected', - Failed = 'failed', -} -enum ConnectingState { - Handshaking = 'handshaking', - Authenticating = 'authenticating', Authenticated = 'authenticated', - Reconnecting = 'reconnecting', - Failed = 'failed', -} -enum ConnectedState { - Preparing = 'preparing', - Ready = 'ready', - Failed = 'failed', -} - -// These enum values are used only in the state machine -enum Event { - Start = 'start', - Failure = 'failure', - WebSocketOpen = 'websocket open', - WebSocketClose = 'websocket close', - ServerHello = 'server hello', - ServerExplicitDisconnect = 'server explicit disconnect', - ServerPingsNotReceived = 'server pings not received', - ServerPongsNotReceived = 'server pongs not received', - ClientExplicitDisconnect = 'client explicit disconnect', - UnableToSocketModeStart = 'unable_to_socket_mode_start', } /** - * An Socket Mode Client allows programs to communicate with the + * A Socket Mode Client allows programs to communicate with the * [Slack Platform's Events API](https://api.slack.com/events-api) over WebSocket connections. * This object uses the EventEmitter pattern to dispatch incoming events * and has a built in send method to acknowledge incoming events over the WebSocket connection. */ export class SocketModeClient extends EventEmitter { /** - * Whether or not the client is currently connected to the web socket + * Whether this client will automatically reconnect when (not manually) disconnected + */ + private autoReconnectEnabled: boolean; + + /** + * This class' logging instance */ - public connected: boolean = false; + private logger: Logger; /** - * Whether or not the client has authenticated to the Socket Mode API. - * This occurs when the connect method completes, - * and a WebSocket URL is available for the client's connection. + * The name used to prefix all logging generated from this class */ - public authenticated: boolean = false; + private static loggerName = 'SocketModeClient'; /** - * Returns true if the underlying WebSocket connection is active. + * The HTTP client used to interact with the Slack API */ - public isActive(): boolean { - this.logger.debug(`Details of isActive() response (connected: ${this.connected}, authenticated: ${this.authenticated}, badConnection: ${this.badConnection})`); - return this.connected && this.authenticated && !this.badConnection; - } + private webClient: WebClient; + + /** + * WebClient options we pass to our WebClient instance + * We also reuse agent and tls for our WebSocket connection + */ + private webClientOptions: WebClientOptions; /** * The underlying WebSocket client instance */ - public websocket?: WebSocket; + public websocket?: SlackWebSocket; + + /** + * Enables ping-pong detailed logging if true + */ + private pingPongLoggingEnabled: boolean; + + /** + * How long to wait for pings from server before timing out + */ + private serverPingTimeoutMS: number; + + /** + * How long to wait for pongs from server before timing out + */ + private clientPingTimeoutMS: number; + + /** + * Internal count for managing the reconnection state + */ + private numOfConsecutiveReconnectionFailures: number = 0; + + private customLoggerProvided: boolean = false; + + /** + * Sentinel tracking if user invoked `disconnect()`; for enforcing shutting down of client + * even if `autoReconnectEnabled` is `true`. + */ + private shuttingDown: boolean = false; public constructor({ logger = undefined, @@ -95,30 +102,30 @@ export class SocketModeClient extends EventEmitter { pingPongLoggingEnabled = false, clientPingTimeout = 5000, serverPingTimeout = 30000, - appToken = undefined, + appToken = '', clientOptions = {}, - }: SocketModeOptions = {}) { + }: SocketModeOptions = { appToken: '' }) { super(); - if (appToken === undefined) { + if (!appToken) { throw new Error('Must provide an App-Level Token when initializing a Socket Mode Client'); } this.pingPongLoggingEnabled = pingPongLoggingEnabled; - this.clientPingTimeoutMillis = clientPingTimeout; - this.lastPongReceivedTimestamp = undefined; - this.serverPingTimeoutMillis = serverPingTimeout; + this.clientPingTimeoutMS = clientPingTimeout; + this.serverPingTimeoutMS = serverPingTimeout; // Setup the logger if (typeof logger !== 'undefined') { + this.customLoggerProvided = true; this.logger = logger; if (typeof logLevel !== 'undefined') { this.logger.debug('The logLevel given to Socket Mode was ignored as you also gave logger'); } } else { - this.logger = getLogger(SocketModeClient.loggerName, logLevel ?? LogLevel.INFO, logger); + this.logger = log.getLogger(SocketModeClient.loggerName, logLevel ?? LogLevel.INFO, logger); } - this.clientOptions = clientOptions; - if (this.clientOptions.retryConfig === undefined) { + this.webClientOptions = clientOptions; + if (this.webClientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting - this.clientOptions.retryConfig = { retries: 100, factor: 1.3 }; + this.webClientOptions.retryConfig = { retries: 100, factor: 1.3 }; } this.webClient = new WebClient('', { logger, @@ -127,29 +134,66 @@ export class SocketModeClient extends EventEmitter { ...clientOptions, }); this.autoReconnectEnabled = autoReconnectEnabled; - this.stateMachine = Finity.start(this.stateMachineConfig); - this.logger.debug('The Socket Mode client is successfully initialized'); + + // bind to error, message and close events emitted from the web socket + this.on('error', (err) => { + this.logger.error(`WebSocket error! ${err}`); + }); + this.on('close', () => { + // Underlying WebSocket connection was closed, possibly reconnect. + if (!this.shuttingDown && this.autoReconnectEnabled) { + this.delayReconnectAttempt(this.start); + } else { + // If reconnect is disabled or user explicitly called `disconnect()`, emit a disconnected state. + this.emit(State.Disconnected); + } + }); + this.on('message', this.onWebSocketMessage.bind(this)); + this.logger.debug('The Socket Mode client has successfully initialized'); } + // PUBLIC METHODS + /** * Start a Socket Mode session app. * This method must be called before any messages can be sent or received, * or to disconnect the client via the `disconnect` method. */ - public start(): Promise { - this.logger.debug('Starting a Socket Mode client ...'); - // Delegate behavior to state machine - this.stateMachine.handle(Event.Start); + public async start(): Promise { // python equiv: SocketModeClient.connect + this.shuttingDown = false; + this.logger.debug('Starting Socket Mode session ...'); + // create a socket connection using SlackWebSocket + this.websocket = new SlackWebSocket({ + url: await this.retrieveWSSURL(), + // web socket events relevant to this client will be emitted into the instance of this class + // see bottom of constructor for where we bind to these events + client: this, + logLevel: this.logger.getLevel(), + logger: this.customLoggerProvided ? this.logger : undefined, + httpAgent: this.webClientOptions.agent, + clientPingTimeoutMS: this.clientPingTimeoutMS, + serverPingTimeoutMS: this.serverPingTimeoutMS, + pingPongLoggingEnabled: this.pingPongLoggingEnabled, + }); + // Return a promise that resolves with the connection information return new Promise((resolve, reject) => { - this.once(State.Connected, (result) => { - this.removeListener(State.Disconnected, reject); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let connectedCallback = (_res: any) => {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let disconnectedCallback = (_err: any) => {}; + connectedCallback = (result) => { + this.removeListener(State.Disconnected, disconnectedCallback); resolve(result); - }); - this.once(State.Disconnected, (err) => { - this.removeListener(State.Connected, resolve); + }; + disconnectedCallback = (err) => { + this.removeListener(State.Connected, connectedCallback); reject(err); - }); + }; + this.once(State.Connected, connectedCallback); + this.once(State.Disconnected, disconnectedCallback); + this.emit(State.Connecting); + this.websocket?.connect(); }); } @@ -158,559 +202,96 @@ export class SocketModeClient extends EventEmitter { * unless you call start() again later. */ public disconnect(): Promise { - return new Promise((resolve, reject) => { - this.logger.debug('Manually disconnecting this Socket Mode client'); - // Resolve (or reject) on disconnect - this.once(State.Disconnected, (err) => { - if (err instanceof Error) { - reject(err); - } else { - resolve(); - } - }); - // Delegate behavior to state machine - this.stateMachine.handle(Event.ClientExplicitDisconnect); - }); - } - - // -------------------------------------------- - // Private methods / properties - // -------------------------------------------- - - /** - * State machine that backs the transition and action behavior - */ - private stateMachine: StateMachine; - - /** - * Internal count for managing the reconnection state - */ - private numOfConsecutiveReconnectionFailures: number = 0; - - /* eslint-disable @typescript-eslint/indent, newline-per-chained-call */ - private connectingStateMachineConfig: Configuration = Finity - .configure() - .global() - .onStateEnter((state) => { - this.logger.debug(`Transitioning to state: ${State.Connecting}:${state}`); - }) - .initialState(ConnectingState.Authenticating) - .do(this.retrieveWSSURL.bind(this)) - .onSuccess().transitionTo(ConnectingState.Authenticated) - .onFailure() - .transitionTo(ConnectingState.Reconnecting).withCondition(this.reconnectingCondition.bind(this)) - .transitionTo(ConnectingState.Failed) - .state(ConnectingState.Reconnecting) - .do(() => new Promise((res, _rej) => { - this.numOfConsecutiveReconnectionFailures += 1; - const millisBeforeRetry = this.clientPingTimeoutMillis * this.numOfConsecutiveReconnectionFailures; - this.logger.debug(`Before trying to reconnect, this client will wait for ${millisBeforeRetry} milliseconds`); - setTimeout(() => { - this.logger.debug('Resolving reconnecting state to continue with reconnect...'); - res(true); - }, millisBeforeRetry); - })) - .onSuccess().transitionTo(ConnectingState.Authenticating) - .onFailure().transitionTo(ConnectingState.Failed) - .state(ConnectingState.Authenticated) - .onEnter(this.configureAuthenticatedWebSocket.bind(this)) - .on(Event.WebSocketOpen).transitionTo(ConnectingState.Handshaking) - .state(ConnectingState.Handshaking) // a state in which to wait until the Event.ServerHello event - .state(ConnectingState.Failed) - .onEnter(this.handleConnectionFailure.bind(this)) - .getConfig(); - - private connectedStateMachineConfig: Configuration = Finity.configure() - .global() - .onStateEnter((state) => { - this.logger.debug(`Transitioning to state: ${State.Connected}:${state}`); - }) - .initialState(ConnectedState.Preparing) - .do(async () => { - if (this.isSwitchingConnection) { - this.switchWebSocketConnection(); - this.badConnection = false; - } - // Start heartbeat to keep track of the WebSocket connection continuing to be alive - // Proactively verifying the connection health by sending ping from this client side - this.startPeriodicallySendingPingToSlack(); - // Reactively verifying the connection health by checking the interval of ping from Slack - this.startMonitoringPingFromSlack(); - }) - .onSuccess().transitionTo(ConnectedState.Ready) - .onFailure().transitionTo(ConnectedState.Failed) - .state(ConnectedState.Failed) - .onEnter(this.handleConnectionFailure.bind(this)) - .getConfig(); - - /** - * Configuration for the state machine - */ - private stateMachineConfig: Configuration = Finity.configure() - .global() - .onStateEnter((state, context) => { - this.logger.debug(`Transitioning to state: ${state}`); - if (state === State.Disconnected) { - // Emits a `disconnected` event with a possible error object (might be undefined) - this.emit(state, context.eventPayload); - } else { - // Emits events: `connecting`, `connected`, `disconnecting`, `reconnecting` - this.emit(state); - } - }) - .initialState(State.Disconnected) - .on(Event.Start) - .transitionTo(State.Connecting) - .on(Event.ClientExplicitDisconnect) - .transitionTo(State.Disconnected) - .state(State.Connecting) - .onEnter(() => { - this.logger.info('Going to establish a new connection to Slack ...'); - }) - .submachine(this.connectingStateMachineConfig) - .on(Event.ServerHello) - .transitionTo(State.Connected) - .on(Event.WebSocketClose) - .transitionTo(State.Reconnecting).withCondition(this.autoReconnectCondition.bind(this)) - .transitionTo(State.Disconnecting) - .on(Event.ClientExplicitDisconnect) - .transitionTo(State.Disconnecting) - .on(Event.Failure) - .transitionTo(State.Disconnected) - .on(Event.WebSocketOpen) - // If submachine not `authenticated` ignore event - .ignore() - .state(State.Connected) - .onEnter(() => { - this.connected = true; - this.logger.info('Now connected to Slack'); - }) - .submachine(this.connectedStateMachineConfig) - .on(Event.WebSocketClose) - .transitionTo(State.Reconnecting) - .withCondition(this.autoReconnectCondition.bind(this)) - .withAction(() => this.markCurrentWebSocketAsInactive()) - .transitionTo(State.Disconnecting) - .on(Event.ClientExplicitDisconnect) - .transitionTo(State.Disconnecting) - .withAction(() => this.markCurrentWebSocketAsInactive()) - .on(Event.ServerPingsNotReceived) - .transitionTo(State.Reconnecting).withCondition(this.autoReconnectCondition.bind(this)) - .transitionTo(State.Disconnecting) - .on(Event.ServerPongsNotReceived) - .transitionTo(State.Reconnecting).withCondition(this.autoReconnectCondition.bind(this)) - .transitionTo(State.Disconnecting) - .on(Event.ServerExplicitDisconnect) - .transitionTo(State.Reconnecting).withCondition(this.autoReconnectCondition.bind(this)) - .transitionTo(State.Disconnecting) - .onExit(() => { - this.terminateActiveHeartBeatJobs(); - }) - .state(State.Reconnecting) - .onEnter(() => { - this.logger.info('Reconnecting to Slack ...'); - }) - .do(async () => { - this.isSwitchingConnection = true; - }) - .onSuccess().transitionTo(State.Connecting) - .onFailure().transitionTo(State.Failed) - .state(State.Disconnecting) - .onEnter(() => { - this.logger.info('Disconnecting ...'); - }) - .do(async () => { - this.terminateActiveHeartBeatJobs(); - this.terminateAllConnections(); - this.logger.info('Disconnected from Slack'); - }) - .onSuccess().transitionTo(State.Disconnected) - .onFailure().transitionTo(State.Failed) - .getConfig(); - - /* eslint-enable @typescript-eslint/indent, newline-per-chained-call */ - - /** - * Whether this client will automatically reconnect when (not manually) disconnected - */ - private autoReconnectEnabled: boolean; - - private secondaryWebsocket?: WebSocket; - - private webClient: WebClient; - - /** - * The name used to prefix all logging generated from this object - */ - private static loggerName = 'SocketModeClient'; - - /** - * This object's logger instance - */ - private logger: Logger; - - /** - * Enables ping-pong detailed logging if true - */ - private pingPongLoggingEnabled: boolean; - - /** - * How long to wait for pings from server before timing out - */ - private serverPingTimeoutMillis: number; - - /** - * Reference to the timeout timer we use to listen to pings from the server - */ - private serverPingTimeout: NodeJS.Timeout | undefined; - - /** - * How long to wait for pings from server before timing out - */ - private clientPingTimeoutMillis: number; - - /** - * Reference to the timeout timer we use to listen to pongs from the server - */ - private clientPingTimeout: NodeJS.Timeout | undefined; - - /** - * The last timetamp that this WebSocket client received pong from the server - */ - private lastPongReceivedTimestamp: number | undefined; - - /** - * Used to see if a WebSocket stops sending heartbeats and is deemed bad - */ - private badConnection: boolean = false; - - /** - * This flag can be true when this client is switching to a new connection. - */ - private isSwitchingConnection: boolean = false; - - /** - * WebClient options we pass to our WebClient instance - * We also reuse agent and tls for our WebSocket connection - */ - private clientOptions: WebClientOptions; - - /** - * Method for sending an outgoing message of an arbitrary type over the WebSocket connection. - * Primarily used to send acknowledgements back to slack for incoming events - * @param id the envelope id - * @param body the message body or string text - */ - private send(id: string, body = {}): Promise { - const _body = typeof body === 'string' ? { text: body } : body; - const message = { envelope_id: id, payload: { ..._body } }; - - return new Promise((resolve, reject) => { - this.logger.debug(`send() method was called in state: ${this.stateMachine.getCurrentState()}, state hierarchy: ${this.stateMachine.getStateHierarchy()}`); - if (this.websocket === undefined) { - this.logger.error('Failed to send a message as the client is not connected'); - reject(sendWhileDisconnectedError()); - } else if (!this.isConnectionReady()) { - this.logger.error('Failed to send a message as the client is not ready'); - reject(sendWhileNotReadyError()); + this.shuttingDown = true; + this.logger.debug('Manually disconnecting this Socket Mode client'); + this.emit(State.Disconnecting); + return new Promise((resolve, _reject) => { + if (!this.websocket) { + this.emit(State.Disconnected); + resolve(); } else { - this.emit('outgoing_message', message); - - const flatMessage = JSON.stringify(message); - this.logger.debug(`Sending a WebSocket message: ${flatMessage}`); - this.websocket.send(flatMessage, (error) => { - if (error !== undefined && error !== null) { - this.logger.error(`Failed to send a WebSocket message (error: ${error.message})`); - return reject(websocketErrorWithOriginal(error)); - } - return resolve(); - }); + // Resolve (or reject) on disconnect + this.once(State.Disconnected, resolve); + this.websocket?.disconnect(); } }); } - private async retrieveWSSURL(): Promise { - try { - this.logger.debug('Going to retrieve a new WSS URL ...'); - return await this.webClient.apps.connections.open({}); - } catch (error) { - this.logger.error(`Failed to retrieve a new WSS URL for reconnection (error: ${error})`); - throw error; - } - } - - private autoReconnectCondition(): boolean { - return this.autoReconnectEnabled; - } - - private reconnectingCondition(context: Context): boolean { - const error = context.error as WebAPICallError; - this.logger.warn(`Failed to start a Socket Mode connection (error: ${error.message})`); - - // Observe this event when the error which causes reconnecting or disconnecting is meaningful - this.emit(Event.UnableToSocketModeStart, error); - let isRecoverable = true; - if (error.code === APICallErrorCode.PlatformError && - (Object.values(UnrecoverableSocketModeStartError) as string[]).includes(error.data.error)) { - isRecoverable = false; - } else if (error.code === APICallErrorCode.RequestError) { - isRecoverable = false; - } else if (error.code === APICallErrorCode.HTTPError) { - isRecoverable = false; - } - return this.autoReconnectEnabled && isRecoverable; - } - - private configureAuthenticatedWebSocket(_state: string, context: Context) { - this.numOfConsecutiveReconnectionFailures = 0; // Reset the failure count - this.authenticated = true; - this.setupWebSocket(context.result.url); - setImmediate(() => { - this.emit(ConnectingState.Authenticated, context.result); - }); - } - - private handleConnectionFailure(_state: string, context: Context) { - this.logger.error(`The internal logic unexpectedly failed (error: ${context.error})`); - // Terminate everything, just in case - this.terminateActiveHeartBeatJobs(); - this.terminateAllConnections(); - // dispatch 'failure' on parent machine to transition out of this submachine's states - this.stateMachine.handle(Event.Failure, context.error); - } - - private markCurrentWebSocketAsInactive(): void { - this.badConnection = true; - this.connected = false; - this.authenticated = false; - } + // PRIVATE/PROTECTED METHODS /** - * Clean up all the remaining connections. + * Initiates a reconnect, taking into account configurable delays and number of reconnect attempts and failures. + * Accepts a callback to invoke after any calculated delays. */ - private terminateAllConnections() { - if (this.secondaryWebsocket !== undefined) { - this.terminateWebSocketSafely(this.secondaryWebsocket); - this.secondaryWebsocket = undefined; - } - if (this.websocket !== undefined) { - this.terminateWebSocketSafely(this.websocket); - this.websocket = undefined; - } + private delayReconnectAttempt(cb: () => Promise): Promise { + this.numOfConsecutiveReconnectionFailures += 1; + const msBeforeRetry = this.clientPingTimeoutMS * this.numOfConsecutiveReconnectionFailures; + this.logger.debug(`Before trying to reconnect, this client will wait for ${msBeforeRetry} milliseconds`); + return new Promise((res, _rej) => { + setTimeout(() => { + this.logger.debug('Continuing with reconnect...'); + this.emit(State.Reconnecting); + cb.apply(this).then(res); + }, msBeforeRetry); + }); } /** - * Set up method for the client's WebSocket instance. This method will attach event listeners. + * Retrieves a new WebSocket URL to connect to. */ - private setupWebSocket(url: string): void { - // initialize the websocket - const options: WebSocket.ClientOptions = { - perMessageDeflate: false, - agent: this.clientOptions.agent, - }; - - let websocket: WebSocket; - let socketId: string; - if (this.websocket === undefined) { - this.websocket = new WebSocket(url, options); - socketId = 'Primary'; - websocket = this.websocket; - } else { - // Set up secondary websocket - // This is used when creating a new connection because the first is about to disconnect - this.secondaryWebsocket = new WebSocket(url, options); - socketId = 'Secondary'; - websocket = this.secondaryWebsocket; - } - - // Attach event listeners - websocket.addEventListener('open', (event) => { - this.logger.debug(`${socketId} WebSocket open event received (connection established)`); - this.stateMachine.handle(Event.WebSocketOpen, event); - }); - websocket.addEventListener('error', (event) => { - this.logger.error(`${socketId} WebSocket error occurred: ${event.message}`); - this.emit('error', websocketErrorWithOriginal(event.error)); - }); - websocket.on('message', this.onWebSocketMessage.bind(this)); - websocket.on('close', (code: number, _data: Buffer) => { - this.logger.debug(`${socketId} WebSocket close event received (code: ${code}, reason: ${_data.toString()})`); - this.stateMachine.handle(Event.WebSocketClose, code); - }); - - // Confirm WebSocket connection is still active - websocket.on('ping', ((data: Buffer) => { - if (this.pingPongLoggingEnabled) { - this.logger.debug(`${socketId} WebSocket received ping from Slack server (data: ${data})`); + private async retrieveWSSURL(): Promise { // python equiv: BaseSocketModeClient.issue_new_wss_url + try { + this.logger.debug('Going to retrieve a new WSS URL ...'); + const resp = await this.webClient.apps.connections.open({}); + if (!resp.url) { + const msg = `apps.connections.open did not return a URL! (response: ${resp})`; + this.logger.error(msg); + throw new Error(msg); } - this.startMonitoringPingFromSlack(); - // Since the `addEventListener` method does not accept listener with data arg in TypeScript, - // we cast this function to any as a workaround - }) as any); // eslint-disable-line @typescript-eslint/no-explicit-any - - websocket.on('pong', ((data: Buffer) => { - if (this.pingPongLoggingEnabled) { - this.logger.debug(`${socketId} WebSocket received pong from Slack server (data: ${data})`); + this.numOfConsecutiveReconnectionFailures = 0; + this.emit(State.Authenticated, resp); + return resp.url; + } catch (error) { + // TODO: Python catches rate limit errors when interacting with this API: https://github.com/slackapi/python-slack-sdk/blob/main/slack_sdk/socket_mode/client.py#L51 + this.logger.error(`Failed to retrieve a new WSS URL (error: ${error})`); + const err = error as WebAPICallError; + let isRecoverable = true; + if (err.code === APICallErrorCode.PlatformError && + (Object.values(UnrecoverableSocketModeStartError) as string[]).includes(err.data.error)) { + isRecoverable = false; + } else if (err.code === APICallErrorCode.RequestError) { + isRecoverable = false; + } else if (err.code === APICallErrorCode.HTTPError) { + isRecoverable = false; } - this.lastPongReceivedTimestamp = new Date().getTime(); - // Since the `addEventListener` method does not accept listener with data arg in TypeScript, - // we cast this function to any as a workaround - }) as any); // eslint-disable-line @typescript-eslint/no-explicit-any - } - - /** - * Tear down the currently working heartbeat jobs. - */ - private terminateActiveHeartBeatJobs() { - if (this.serverPingTimeout !== undefined) { - clearTimeout(this.serverPingTimeout); - this.serverPingTimeout = undefined; - this.logger.debug('Cancelled the job waiting for ping from Slack'); - } - if (this.clientPingTimeout !== undefined) { - clearTimeout(this.clientPingTimeout); - this.clientPingTimeout = undefined; - this.logger.debug('Terminated the heart beat job'); - } - } - - /** - * Switch the active connection to the secondary if exists. - */ - private switchWebSocketConnection(): void { - if (this.secondaryWebsocket !== undefined && this.websocket !== undefined) { - this.logger.debug('Switching to the secondary connection ...'); - // Currently have two WebSocket objects, so tear down the older one - const oldWebsocket = this.websocket; - // Switch to the new one here - this.websocket = this.secondaryWebsocket; - this.secondaryWebsocket = undefined; - this.logger.debug('Switched to the secondary connection'); - // Swithcing the connection is done - this.isSwitchingConnection = false; - - // Clean up the old one - this.terminateWebSocketSafely(oldWebsocket); - this.logger.debug('Terminated the old connection'); - } - } - - /** - * Tear down method for the client's WebSocket instance. - * This method undoes the work in setupWebSocket(url). - */ - private terminateWebSocketSafely(websocket: WebSocket): void { - if (websocket !== undefined) { - try { - websocket.removeAllListeners('open'); - websocket.removeAllListeners('close'); - websocket.removeAllListeners('error'); - websocket.removeAllListeners('message'); - websocket.terminate(); - } catch (e) { - this.logger.error(`Failed to terminate a connection (error: ${e})`); + if (this.autoReconnectEnabled && isRecoverable) { + return await this.delayReconnectAttempt(this.retrieveWSSURL); } + throw error; } } - private startPeriodicallySendingPingToSlack(): void { - if (this.clientPingTimeout !== undefined) { - clearTimeout(this.clientPingTimeout); - } - // re-init for new monitoring loop - this.lastPongReceivedTimestamp = undefined; - let pingAttemptCount = 0; - - if (!this.badConnection) { - this.clientPingTimeout = setInterval(() => { - const nowMillis = new Date().getTime(); - try { - const pingMessage = `Ping from client (${nowMillis})`; - this.websocket?.ping(pingMessage); - if (this.lastPongReceivedTimestamp === undefined) { - pingAttemptCount += 1; - } else { - pingAttemptCount = 0; - } - if (this.pingPongLoggingEnabled) { - this.logger.debug(`Sent ping to Slack: ${pingMessage}`); - } - } catch (e) { - this.logger.error(`Failed to send ping to Slack (error: ${e})`); - this.handlePingPongErrorReconnection(); - return; - } - let isInvalid: boolean = pingAttemptCount > 5; - if (this.lastPongReceivedTimestamp !== undefined) { - const millis = nowMillis - this.lastPongReceivedTimestamp; - isInvalid = millis > this.clientPingTimeoutMillis; - } - if (isInvalid) { - this.logger.info(`A pong wasn't received from the server before the timeout of ${this.clientPingTimeoutMillis}ms!`); - this.handlePingPongErrorReconnection(); - } - }, this.clientPingTimeoutMillis / 3); - this.logger.debug('Started running a new heart beat job'); - } - } - - private handlePingPongErrorReconnection() { - try { - this.badConnection = true; - this.stateMachine.handle(Event.ServerPongsNotReceived); - } catch (e) { - this.logger.error(`Failed to reconnect to Slack (error: ${e})`); - } - } - - /** - * Confirms WebSocket connection is still active - * fires whenever a ping event is received - */ - private startMonitoringPingFromSlack(): void { - if (this.serverPingTimeout !== undefined) { - clearTimeout(this.serverPingTimeout); - } - // Don't start heartbeat if connection is already deemed bad - if (!this.badConnection) { - this.serverPingTimeout = setTimeout(() => { - this.logger.info(`A ping wasn't received from the server before the timeout of ${this.serverPingTimeoutMillis}ms!`); - if (this.isConnectionReady()) { - this.badConnection = true; - // Opens secondary WebSocket and teardown original once that is ready - this.stateMachine.handle(Event.ServerPingsNotReceived); - } - }, this.serverPingTimeoutMillis); - } - } - - private isConnectionReady() { - const currentState = this.stateMachine.getCurrentState(); - const stateHierarchy = this.stateMachine.getStateHierarchy(); - return currentState === State.Connected && - stateHierarchy !== undefined && - stateHierarchy.length >= 2 && - // When the primary state is State.Connected, the second one is always set by the sub state machine - stateHierarchy[1].toString() === ConnectedState.Ready; - } - /** * `onmessage` handler for the client's WebSocket. - * This will parse the payload and dispatch the relevant events for each incoming message. + * This will parse the payload and dispatch the application-relevant events for each incoming message. + * Mediates: + * - raising the State.Connected event (when Slack sends a type:hello message) + * - disconnecting the underlying socket (when Slack sends a type:disconnect message) */ protected async onWebSocketMessage(data: WebSocket.RawData, isBinary: boolean): Promise { if (isBinary) { this.logger.debug('Unexpected binary message received, ignoring.'); return; } - this.logger.debug(`Received a message on the WebSocket: ${data.toString()}`); + const payload = data.toString(); + this.logger.debug(`Received a message on the WebSocket: ${payload}`); // Parse message into slack event let event: { type: string; reason: string; // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload: { [key: string]: any }; + payload: Record; envelope_id: string; retry_attempt?: number; // type: events_api retry_reason?: string; // type: events_api @@ -718,30 +299,29 @@ export class SocketModeClient extends EventEmitter { }; try { - event = JSON.parse(data.toString()); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (parseError: any) { + event = JSON.parse(payload); + } catch (parseError) { // Prevent application from crashing on a bad message, but log an error to bring attention this.logger.debug( - `Unable to parse an incoming WebSocket message (will ignore): ${parseError.message}, ${data.toString()}`, + `Unable to parse an incoming WebSocket message (will ignore): ${parseError}, ${payload}`, ); return; } - // Internal event handlers + // Slack has finalized the handshake with a hello message; we are good to go. if (event.type === 'hello') { - this.stateMachine.handle(Event.ServerHello); + this.emit(State.Connected); return; } + // Slack is recycling the pod handling the connection (or otherwise requires the client to reconnect) if (event.type === 'disconnect') { - // Refresh the WebSocket connection when prompted by Slack backend - this.logger.debug(`Received "disconnect" (reason: ${event.reason}) message - will ${this.autoReconnectEnabled ? 'attempt reconnect' : 'disconnect (due to autoReconnectEnabled=false)'}`); - this.stateMachine.handle(Event.ServerExplicitDisconnect); + this.logger.debug(`Received "${event.type}" (${event.reason}) message - disconnecting.${this.autoReconnectEnabled ? ' Will reconnect.' : ''}`); + this.websocket?.disconnect(); return; } - // Define Ack + // Define Ack, a helper method for acknowledging events incoming from Slack const ack = async (response: Record): Promise => { if (this.logger.getLevel() === LogLevel.DEBUG) { this.logger.debug(`Calling ack() - type: ${event.type}, envelope_id: ${event.envelope_id}, data: ${JSON.stringify(response)}`); @@ -782,6 +362,41 @@ export class SocketModeClient extends EventEmitter { accepts_response_payload: event.accepts_response_payload, }); } + + /** + * Method for sending an outgoing message of an arbitrary type over the WebSocket connection. + * Primarily used to send acknowledgements back to slack for incoming events + * @param id the envelope id + * @param body the message body or string text + */ + private send(id: string, body = {}): Promise { + const _body = typeof body === 'string' ? { text: body } : body; + const message = { envelope_id: id, payload: { ..._body } }; + + return new Promise((resolve, reject) => { + const wsState = this.websocket?.readyState; + this.logger.debug(`send() method was called (WebSocket state: ${wsState ? WS_READY_STATES[wsState] : 'uninitialized'})`); + if (this.websocket === undefined) { + this.logger.error('Failed to send a message as the client is not connected'); + reject(sendWhileDisconnectedError()); + } else if (!this.websocket.isActive()) { + this.logger.error('Failed to send a message as the client has no active connection'); + reject(sendWhileNotReadyError()); + } else { + this.emit('outgoing_message', message); + + const flatMessage = JSON.stringify(message); + this.logger.debug(`Sending a WebSocket message: ${flatMessage}`); + this.websocket.send(flatMessage, (error) => { + if (error) { + this.logger.error(`Failed to send a WebSocket message (error: ${error})`); + return reject(websocketErrorWithOriginal(error)); + } + return resolve(); + }); + } + }); + } } /* Instrumentation */ diff --git a/packages/socket-mode/src/SocketModeOptions.ts b/packages/socket-mode/src/SocketModeOptions.ts index cdaf1f2b5..a55fc15f8 100644 --- a/packages/socket-mode/src/SocketModeOptions.ts +++ b/packages/socket-mode/src/SocketModeOptions.ts @@ -1,13 +1,47 @@ -import { WebClientOptions } from '@slack/web-api'; -import { LogLevel, Logger } from './logger'; +import type { WebClientOptions } from '@slack/web-api'; +import type { LogLevel, Logger } from './logger'; export interface SocketModeOptions { - appToken?: string; // app level token + /** + * The App-level token associated with your app, located under the Basic Information page on api.slack.com/apps. + */ + appToken: string; + /** + * An instance of `@slack/logger`'s Logger interface, to send log messages to. + */ logger?: Logger; + /** + * An instance of `@slack/logger`'s LogLevel enum, setting the minimum log level to emit log messages for. + */ logLevel?: LogLevel; + /** + * Whether the client should automatically reconnect when the socket mode connection is disrupted. Defaults to `true`. + * Note that disconnects are regular and expected when using Socket Mode, so setting this to `false` will likely lead + * to a disconnected client after some amount of time. + */ autoReconnectEnabled?: boolean; + /** + * How long the client should wait for a `pong` response to the client's `ping` to the server, in milliseconds. + * If this timeout is hit, the client will attempt to reconnect if `autoReconnectEnabled` is `true`; + * otherwise, it will disconnect. + * Defaults to 5,000. + */ clientPingTimeout?: number; + /** + * How long the client should wait for `ping` messages from the server, in milliseconds. + * If this timeout is hit, the client will attempt to reconnect if `autoReconnectEnabled` is `true`; + * otherwise, it will disconnect. + * Defaults to 30,000. + */ serverPingTimeout?: number; + /** + * Should logging related to `ping` and `pong` messages between the client and server be logged at a + * `LogLevel.DEBUG` level. Defaults to `false. + */ pingPongLoggingEnabled?: boolean; + /** + * The `@slack/web-api` `WebClientOptions` to provide to the HTTP client interacting with Slack's HTTP API. + * Useful for setting retry configurations, TLS and HTTP Agent options. + */ clientOptions?: Omit; } diff --git a/packages/socket-mode/src/logger.ts b/packages/socket-mode/src/logger.ts index f2ffe5545..eb3e182e2 100644 --- a/packages/socket-mode/src/logger.ts +++ b/packages/socket-mode/src/logger.ts @@ -1,26 +1,25 @@ import { Logger, LogLevel, ConsoleLogger } from '@slack/logger'; -export { Logger, LogLevel } from '@slack/logger'; +export { Logger, LogLevel }; let instanceCount = 0; -/** - * INTERNAL interface for getting or creating a named Logger. - */ -export function getLogger(name: string, level: LogLevel, existingLogger?: Logger): Logger { - // Get a unique ID for the logger. - const instanceId = instanceCount; - instanceCount += 1; +export default { + getLogger: function getLogger(name: string, level: LogLevel, existingLogger?: Logger): Logger { + // Get a unique ID for the logger. + const instanceId = instanceCount; + instanceCount += 1; - // Set up the logger. - const logger: Logger = (() => { - if (existingLogger !== undefined) { return existingLogger; } - return new ConsoleLogger(); - })(); - logger.setName(`socket-mode:${name}:${instanceId}`); - if (level !== undefined) { - logger.setLevel(level); - } + // Set up the logger. + const logger: Logger = (() => { + if (existingLogger !== undefined) { return existingLogger; } + return new ConsoleLogger(); + })(); + logger.setName(`socket-mode:${name}:${instanceId}`); + if (level !== undefined) { + logger.setLevel(level); + } - return logger; -} + return logger; + }, +}; diff --git a/packages/socket-mode/test/integration.spec.js b/packages/socket-mode/test/integration.spec.js index 396141db8..5cbe10f96 100644 --- a/packages/socket-mode/test/integration.spec.js +++ b/packages/socket-mode/test/integration.spec.js @@ -40,12 +40,17 @@ describe('Integration tests with a WebSocket server', () => { exposed_ws_connection = ws; }); }); - afterEach(() => { + afterEach(async () => { server.close(); server = null; wss.close(); wss = null; exposed_ws_connection = null; + if (client) { + // if client is still defined, force disconnect, in case a test times out and the test was unable to call disconnect + // prevents test process from freezing due to open connections + await client.disconnect(); + } client = null; }); describe('establishing connection, receiving valid messages', () => { @@ -110,7 +115,7 @@ describe('Integration tests with a WebSocket server', () => { }); }); describe('failure modes / unexpected messages sent to client', () => { - let debugLoggerSpy = sinon.stub(); + let debugLoggerSpy = sinon.stub(); // add the following to expose further logging: .callsFake(console.log); const noop = () => {}; beforeEach(() => { client = new SocketModeClient({ appToken: 'whatever', clientOptions: { @@ -127,7 +132,7 @@ describe('Integration tests with a WebSocket server', () => { }); it('should ignore binary messages', async () => { client.on('connected', () => { - exposed_ws_connection.send(null); + exposed_ws_connection.send(Buffer.from([1,2,3,4]), { binary: true }); }); await client.start(); await sleep(10); @@ -148,7 +153,7 @@ describe('Integration tests with a WebSocket server', () => { beforeEach(() => { client = new SocketModeClient({ appToken: 'whatever', logLevel: LogLevel.ERROR, clientOptions: { slackApiUrl: `http://localhost:${HTTP_PORT}/` - }}); + }, clientPingTimeout: 25}); }); it('raises connecting event during `start()`', async () => { let raised = false; @@ -238,6 +243,78 @@ describe('Integration tests with a WebSocket server', () => { await client.disconnect(); }); }); + describe('related to ping/pong events', () => { + beforeEach(() => { + client = new SocketModeClient({ appToken: 'whatever', logLevel: LogLevel.DEBUG, clientOptions: { + slackApiUrl: `http://localhost:${HTTP_PORT}/` + }, clientPingTimeout: 25, serverPingTimeout: 25, pingPongLoggingEnabled: false }); + }); + it('should reconnect if server does not send `ping` message within specified server ping timeout', async () => { + await client.start(); + // create a waiter for post-reconnect connected event + const reconnectedWaiter = new Promise((res) => client.on('connected', res)); + exposed_ws_connection.ping(); + // we set server and client ping timeout to 25, so waiting for 50 + a bit should force a reconnect + await sleep(60); + // if we pass the point where the reconnectedWaiter succeeded, then we have verified the reconnection succeeded + // and this test can be considered passing. if we time out here, then that is an indication of a failure. + await reconnectedWaiter; + await client.disconnect(); + }); + it('should reconnect if server does not respond with `pong` message within specified client ping timeout ', async () => { + wss.close(); + // override the web socket server so that it DOESNT auto-respond to ping messages with a pong + wss = new WebSocketServer({ port: WSS_PORT, autoPong: false }); + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.fail(err); + }); + // Send `Event.ServerHello` + ws.send(JSON.stringify({type: 'hello'})); + exposed_ws_connection = ws; + }); + await client.start(); + // create a waiter for post-reconnect connected event + const reconnectedWaiter = new Promise((res) => client.on('connected', res)); + // we set server and client ping timeout to 25, so waiting for 50 + a bit should force a reconnect + await sleep(60); + // if we pass the point where the reconnectedWaiter succeeded, then we have verified the reconnection succeeded + // and this test can be considered passing. if we time out here, then that is an indication of a failure. + await reconnectedWaiter; + await client.disconnect(); + }); + it('should reconnect if server does not respond with `pong` message within specified client ping timeout after initially responding with `pong`', async () => { + wss.close(); + // override the web socket server so that it DOESNT auto-respond to ping messages with a pong, except for the first time + let hasPonged = false; + wss = new WebSocketServer({ port: WSS_PORT, autoPong: false }); + wss.on('connection', (ws) => { + ws.on('error', (err) => { + assert.fail(err); + }); + ws.on('ping', () => { + // respond to a pong once + // we do this to simulate the server initially responding well to pings, but then failing to do so at some point + if (!hasPonged) { + hasPonged = true; + ws.pong(); + } + }); + // Send `Event.ServerHello` + ws.send(JSON.stringify({type: 'hello'})); + exposed_ws_connection = ws; + }); + await client.start(); + // create a waiter for post-reconnect connected event + const reconnectedWaiter = new Promise((res) => client.on('connected', res)); + // we set server and client ping timeout to 25, so waiting for 50 + a bit should force a reconnect + await sleep(60); + // if we pass the point where the reconnectedWaiter succeeded, then we have verified the reconnection succeeded + // and this test can be considered passing. if we time out here, then that is an indication of a failure. + await reconnectedWaiter; + await client.disconnect(); + }); + }); }); }); }); diff --git a/packages/socket-mode/tsconfig.json b/packages/socket-mode/tsconfig.json index c21aa5718..e4741c0a1 100644 --- a/packages/socket-mode/tsconfig.json +++ b/packages/socket-mode/tsconfig.json @@ -19,17 +19,13 @@ "*": ["./types/*"] }, "esModuleInterop" : true, - - // Not using this setting because its only used to require the package.json file, and that would change the - // structure of the files in the dist directory because package.json is not located inside src. It would be nice - // to use import instead of require(), but its not worth the tradeoff of restructuring the build (for now). - // "resolveJsonModule": true, + "resolveJsonModule": true }, "include": [ "src/**/*" ], "exclude": [ - "src/**/*.spec.js", + "src/**/*.spec.ts", "src/**/*.js" ], "jsdoc": {