"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SocketModeClient = void 0; const eventemitter3_1 = require("eventemitter3"); const ws_1 = __importDefault(require("ws")); const finity_1 = __importDefault(require("finity")); const web_api_1 = require("@slack/web-api"); const logger_1 = require("./logger"); const errors_1 = require("./errors"); const UnrecoverableSocketModeStartError_1 = require("./UnrecoverableSocketModeStartError"); 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 var State; (function (State) { State["Connecting"] = "connecting"; State["Connected"] = "connected"; State["Reconnecting"] = "reconnecting"; State["Disconnecting"] = "disconnecting"; State["Disconnected"] = "disconnected"; State["Failed"] = "failed"; })(State || (State = {})); var ConnectingState; (function (ConnectingState) { ConnectingState["Handshaking"] = "handshaking"; ConnectingState["Authenticating"] = "authenticating"; ConnectingState["Authenticated"] = "authenticated"; ConnectingState["Reconnecting"] = "reconnecting"; ConnectingState["Failed"] = "failed"; })(ConnectingState || (ConnectingState = {})); var ConnectedState; (function (ConnectedState) { ConnectedState["Preparing"] = "preparing"; ConnectedState["Ready"] = "ready"; ConnectedState["Failed"] = "failed"; })(ConnectedState || (ConnectedState = {})); // These enum values are used only in the state machine var Event; (function (Event) { Event["Start"] = "start"; Event["Failure"] = "failure"; Event["WebSocketOpen"] = "websocket open"; Event["WebSocketClose"] = "websocket close"; Event["ServerHello"] = "server hello"; Event["ServerExplicitDisconnect"] = "server explicit disconnect"; Event["ServerPingsNotReceived"] = "server pings not received"; Event["ServerPongsNotReceived"] = "server pongs not received"; Event["ClientExplicitDisconnect"] = "client explicit disconnect"; Event["UnableToSocketModeStart"] = "unable_to_socket_mode_start"; })(Event || (Event = {})); /** * An 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. */ class SocketModeClient extends eventemitter3_1.EventEmitter { /** * Returns true if the underlying WebSocket connection is active. */ isActive() { this.logger.debug(`Details of isActive() response (connected: ${this.connected}, authenticated: ${this.authenticated}, badConnection: ${this.badConnection})`); return this.connected && this.authenticated && !this.badConnection; } constructor({ logger = undefined, logLevel = undefined, autoReconnectEnabled = true, pingPongLoggingEnabled = false, clientPingTimeout = 5000, serverPingTimeout = 30000, appToken = undefined, clientOptions = {}, } = {}) { super(); /** * Whether or not the client is currently connected to the web socket */ this.connected = false; /** * 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. */ this.authenticated = false; /** * Internal count for managing the reconnection state */ this.numOfConsecutiveReconnectionFailures = 0; /* eslint-disable @typescript-eslint/indent, newline-per-chained-call */ this.connectingStateMachineConfig = finity_1.default.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) => { // Trying to reconnect after waiting for a bit... 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.emit(ConnectingState.Authenticating); 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(); this.connectedStateMachineConfig = finity_1.default.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 */ this.stateMachineConfig = finity_1.default.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(); /** * Used to see if a WebSocket stops sending heartbeats and is deemed bad */ this.badConnection = false; /** * This flag can be true when this client is switching to a new connection. */ this.isSwitchingConnection = false; if (appToken === undefined) { 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; // Setup the logger if (typeof logger !== 'undefined') { 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 = (0, logger_1.getLogger)(SocketModeClient.loggerName, logLevel !== null && logLevel !== void 0 ? logLevel : logger_1.LogLevel.INFO, logger); } this.clientOptions = clientOptions; if (this.clientOptions.retryConfig === undefined) { // For faster retries of apps.connections.open API calls for reconnecting this.clientOptions.retryConfig = { retries: 100, factor: 1.3 }; } this.webClient = new web_api_1.WebClient('', Object.assign({ logger, logLevel: this.logger.getLevel(), headers: { Authorization: `Bearer ${appToken}` } }, clientOptions)); this.autoReconnectEnabled = autoReconnectEnabled; this.stateMachine = finity_1.default.start(this.stateMachineConfig); this.logger.debug('The Socket Mode client is successfully initialized'); } /** * Start a Socket Mode session app. * It may take a few milliseconds before being connected. * This method must be called before any messages can be sent or received. */ start() { this.logger.debug('Starting a Socket Mode client ...'); // Delegate behavior to state machine this.stateMachine.handle(Event.Start); // Return a promise that resolves with the connection information return new Promise((resolve, reject) => { this.once(ConnectingState.Authenticated, (result) => { this.removeListener(State.Disconnected, reject); resolve(result); }); this.once(State.Disconnected, (err) => { this.removeListener(ConnectingState.Authenticated, resolve); reject(err); }); }); } /** * End a Socket Mode session. After this method is called no messages will be sent or received * unless you call start() again later. */ disconnect() { 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); }); } /** * 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 */ send(id, body = {}) { const _body = typeof body === 'string' ? { text: body } : body; const message = { envelope_id: id, payload: Object.assign({}, _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((0, errors_1.sendWhileDisconnectedError)()); } else if (!this.isConnectionReady()) { this.logger.error('Failed to send a message as the client is not ready'); reject((0, errors_1.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 !== undefined && error !== null) { this.logger.error(`Failed to send a WebSocket message (error: ${error.message})`); return reject((0, errors_1.websocketErrorWithOriginal)(error)); } return resolve(); }); } }); } async retrieveWSSURL() { 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; } } autoReconnectCondition() { return this.autoReconnectEnabled; } reconnectingCondition(context) { const error = context.error; 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 === web_api_1.ErrorCode.PlatformError && Object.values(UnrecoverableSocketModeStartError_1.UnrecoverableSocketModeStartError).includes(error.data.error)) { isRecoverable = false; } else if (error.code === web_api_1.ErrorCode.RequestError) { isRecoverable = false; } else if (error.code === web_api_1.ErrorCode.HTTPError) { isRecoverable = false; } return this.autoReconnectEnabled && isRecoverable; } configureAuthenticatedWebSocket(_state, context) { this.numOfConsecutiveReconnectionFailures = 0; // Reset the failure count this.authenticated = true; this.setupWebSocket(context.result.url); setImmediate(() => { this.emit(ConnectingState.Authenticated, context.result); }); } handleConnectionFailure(_state, 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); } markCurrentWebSocketAsInactive() { this.badConnection = true; this.connected = false; this.authenticated = false; } /** * Clean up all the remaining connections. */ terminateAllConnections() { if (this.secondaryWebsocket !== undefined) { this.terminateWebSocketSafely(this.secondaryWebsocket); this.secondaryWebsocket = undefined; } if (this.websocket !== undefined) { this.terminateWebSocketSafely(this.websocket); this.websocket = undefined; } } /** * Set up method for the client's WebSocket instance. This method will attach event listeners. */ setupWebSocket(url) { // initialize the websocket const options = { perMessageDeflate: false, agent: this.clientOptions.agent, }; let websocket; let socketId; if (this.websocket === undefined) { this.websocket = new ws_1.default(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 ws_1.default(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('close', (event) => { this.logger.debug(`${socketId} WebSocket close event received (code: ${event.code}, reason: ${event.reason})`); this.stateMachine.handle(Event.WebSocketClose, event); }); websocket.addEventListener('error', (event) => { this.logger.error(`${socketId} WebSocket error occurred: ${event.message}`); this.emit('error', (0, errors_1.websocketErrorWithOriginal)(event.error)); }); websocket.addEventListener('message', this.onWebSocketMessage.bind(this)); // Confirm WebSocket connection is still active websocket.addEventListener('ping', ((data) => { if (this.pingPongLoggingEnabled) { this.logger.debug(`${socketId} WebSocket received ping from Slack server (data: ${data})`); } this.startMonitoringPingFromSlack(); // Since the `addEventListener` method does not accept listener with data arg in TypeScript, // we cast this function to any as a workaround })); // eslint-disable-line @typescript-eslint/no-explicit-any websocket.addEventListener('pong', ((data) => { if (this.pingPongLoggingEnabled) { this.logger.debug(`${socketId} WebSocket received pong from Slack server (data: ${data})`); } 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 })); // eslint-disable-line @typescript-eslint/no-explicit-any } /** * Tear down the currently working heartbeat jobs. */ 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. */ switchWebSocketConnection() { 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). */ terminateWebSocketSafely(websocket) { 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})`); } } } startPeriodicallySendingPingToSlack() { 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(() => { var _a; const nowMillis = new Date().getTime(); try { const pingMessage = `Ping from client (${nowMillis})`; (_a = this.websocket) === null || _a === void 0 ? void 0 : _a.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 = 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'); } } 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 */ startMonitoringPingFromSlack() { 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); } } 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. */ async onWebSocketMessage({ data }) { this.logger.debug(`Received a message on the WebSocket: ${data}`); // Parse message into slack event let event; try { event = JSON.parse(data); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (parseError) { // Prevent application from crashing on a bad message, but log an error to bring attention this.logger.error(`Unable to parse an incoming WebSocket message: ${parseError.message}`); return; } // Internal event handlers if (event.type === 'hello') { this.stateMachine.handle(Event.ServerHello); return; } 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); return; } // Define Ack const ack = async (response) => { if (this.logger.getLevel() === logger_1.LogLevel.DEBUG) { this.logger.debug(`Calling ack() - type: ${event.type}, envelope_id: ${event.envelope_id}, data: ${JSON.stringify(response)}`); } await this.send(event.envelope_id, response); }; // For events_api messages, expose the type of the event if (event.type === 'events_api') { this.emit(event.payload.event.type, { ack, envelope_id: event.envelope_id, body: event.payload, event: event.payload.event, retry_num: event.retry_attempt, retry_reason: event.retry_reason, accepts_response_payload: event.accepts_response_payload, }); } else { // Emit just ack and body for all other types of messages this.emit(event.type, { ack, envelope_id: event.envelope_id, body: event.payload, accepts_response_payload: event.accepts_response_payload, }); } // Emitter for all slack events // (this can be used in tools like bolt-js) this.emit('slack_event', { ack, envelope_id: event.envelope_id, type: event.type, body: event.payload, retry_num: event.retry_attempt, retry_reason: event.retry_reason, accepts_response_payload: event.accepts_response_payload, }); } } exports.SocketModeClient = SocketModeClient; /** * The name used to prefix all logging generated from this object */ SocketModeClient.loggerName = 'SocketModeClient'; /* Instrumentation */ (0, web_api_1.addAppMetadata)({ name: packageJson.name, version: packageJson.version }); exports.default = SocketModeClient; //# sourceMappingURL=SocketModeClient.js.map