UNPKG

@slack/rtm-api

Version:

Official library for using the Slack Platform's Real Time Messaging API

517 lines 24 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RTMClient = void 0; const web_api_1 = require("@slack/web-api"); const eventemitter3_1 = require("eventemitter3"); const finity_1 = __importDefault(require("finity")); const p_cancelable_1 = __importDefault(require("p-cancelable")); const p_queue_1 = __importDefault(require("p-queue")); const ws_1 = __importDefault(require("ws")); const KeepAlive_1 = require("./KeepAlive"); const errors_1 = require("./errors"); const logger_1 = require("./logger"); const packageJson = require('../package.json'); /* * Helpers */ // NOTE: there may be a better way to add metadata to an error about being "unrecoverable" than to keep an // independent enum, probably a Set (this isn't used as a type). var UnrecoverableRTMStartError; (function (UnrecoverableRTMStartError) { UnrecoverableRTMStartError["NotAuthed"] = "not_authed"; UnrecoverableRTMStartError["InvalidAuth"] = "invalid_auth"; UnrecoverableRTMStartError["AccountInactive"] = "account_inactive"; UnrecoverableRTMStartError["UserRemovedFromTeam"] = "user_removed_from_team"; UnrecoverableRTMStartError["TeamDisabled"] = "team_disabled"; })(UnrecoverableRTMStartError || (UnrecoverableRTMStartError = {})); /** * An RTMClient allows programs to communicate with the {@link https://api.slack.com/rtm|Slack Platform's RTM API}. * This object uses the EventEmitter pattern to dispatch incoming events and has several methods for sending outgoing * messages. */ class RTMClient extends eventemitter3_1.EventEmitter { constructor(token, { slackApiUrl = 'https://slack.com/api/', logger = undefined, logLevel = logger_1.LogLevel.INFO, retryConfig, agent = undefined, autoReconnect = true, useRtmConnect = true, clientPingTimeout, serverPongTimeout, replyAckOnReconnectTimeout = 2000, tls = undefined, webClient, } = {}) { super(); /** * Whether or not the client is currently connected to the RTM API */ this.connected = false; /** * Whether or not the client has authenticated to the RTM API. This occurs when the connect method * completes, and a WebSocket URL is available for the client's connection. */ this.authenticated = false; /** * The number of milliseconds to wait upon connection for reply messages from the previous connection. The default * value is 2 seconds. */ this.replyAckOnReconnectTimeout = 2000; /** * Configuration for the state machine */ this.stateMachineConfig = finity_1.default.configure() /* eslint-disable @typescript-eslint/indent, newline-per-chained-call */ .initialState('disconnected') .on('start') .transitionTo('connecting') .on('explicit disconnect') .transitionTo('disconnected') .onEnter(() => { // each client should start out with the outgoing event queue paused this.logger.debug('pausing outgoing event queue'); this.outgoingEventQueue.pause(); // when a formerly connected client gets disconnected, all outgoing messages whose promises were waiting // for a reply from the server should be canceled for (const p of this.awaitingReplyList) { p.cancel(); } }) .state('connecting') .submachine(finity_1.default.configure() .initialState('authenticating') .do(() => { // determine which Web API method to use for the connection const connectMethod = this.useRtmConnect ? 'rtm.connect' : 'rtm.start'; return this.webClient .apiCall(connectMethod, this.startOpts !== undefined ? Object.assign({}, this.startOpts) : {}) .then((result) => { const startData = result; // capture identity information this.activeUserId = startData.self.id; this.activeTeamId = startData.team.id; return result; }); }) .onSuccess() .transitionTo('authenticated') .onFailure() .transitionTo('reconnecting') .withCondition((context) => { const error = context.error; this.logger.info(`unable to RTM start: ${error.message}`); // Observe this event when the error which causes reconnecting or disconnecting is meaningful this.emit('unable_to_rtm_start', error); let isRecoverable = true; if (error.code === web_api_1.ErrorCode.PlatformError && Object.values(UnrecoverableRTMStartError).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.autoReconnect && isRecoverable; }) .transitionTo('failed') .state('authenticated') .onEnter((_state, context) => { this.authenticated = true; this.setupWebsocket(context.result.url); setImmediate(() => { this.emit('authenticated', context.result); }); }) .on('websocket open') .transitionTo('handshaking') .state('handshaking') // a state in which to wait until the 'server hello' event .state('failed') .onEnter((_state, context) => { // dispatch 'failure' on parent machine to transition out of this submachine's states this.stateMachine.handle('failure', context.error); }) .global() .onStateEnter((state) => { this.logger.debug(`transitioning to state: connecting:${state}`); }) .getConfig()) .on('server hello') .transitionTo('connected') .on('websocket close') .transitionTo('reconnecting') .withCondition(() => this.autoReconnect) .transitionTo('disconnected') .withAction(() => { // this transition circumvents the 'disconnecting' state (since the websocket is already closed), so we need // to execute its onExit behavior here. this.teardownWebsocket(); }) .on('failure') .transitionTo('disconnected') .on('explicit disconnect') .transitionTo('disconnecting') .state('connected') .onEnter(() => { this.connected = true; }) .submachine(finity_1.default.configure() .initialState('resuming') // when a reply to the last message sent is received, we assume that the client is "caught up" from its // previous connection .on('replay finished') .transitionTo('ready') // when this client is connecting for the first time, or if the last message sent on the previous connection // would not get a reply from the server, or if for any other reason we do not receive a reply to the last // message sent - after a timeout, we assume that the client is "caught up" .onTimeout(this.replyAckOnReconnectTimeout) .transitionTo('ready') .onExit(() => { // once all replay messages are processed, if there are any more messages awaiting a reply message, let // them know that there are none expected to arrive. for (const p of this.awaitingReplyList) { p.cancel(); } }) .state('ready') .onEnter(() => { this.keepAlive.start(this); // the transition isn't done yet, so we delay the following statement until after the event loop returns setImmediate(() => { this.logger.debug('resuming outgoing event queue'); this.outgoingEventQueue.start(); this.emit('ready'); }); }) .global() .onStateEnter((state) => { this.logger.debug(`transitioning to state: connected:${state}`); }) .getConfig()) .on('websocket close') .transitionTo('reconnecting') .withCondition(() => this.autoReconnect) .transitionTo('disconnected') .withAction(() => { // this transition circumvents the 'disconnecting' state (since the websocket is already closed), so we need // to execute its onExit behavior here. this.teardownWebsocket(); }) .on('explicit disconnect') .transitionTo('disconnecting') .onExit(() => { this.connected = false; this.authenticated = false; // clear data that is now stale this.activeUserId = undefined; this.activeTeamId = undefined; this.keepAlive.stop(); this.outgoingEventQueue.pause(); }) .state('disconnecting') .onEnter(() => { // Most of the time, a websocket will exist. The only time it does not is when transitioning from connecting, // before the rtm.start() has finished and the websocket hasn't been set up. if (this.websocket !== undefined) { this.websocket.close(); } }) .on('websocket close') .transitionTo('disconnected') .onExit(() => this.teardownWebsocket()) // reconnecting is just like disconnecting, except that the websocket should already be closed before we enter // this state, and that the next state should be connecting. .state('reconnecting') .do(() => { this.keepAlive.stop(); return Promise.resolve(true); }) .onSuccess() .transitionTo('connecting') .onExit(() => this.teardownWebsocket()) .global() .onStateEnter((state, context) => { this.logger.debug(`transitioning to state: ${state}`); if (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); } }) .getConfig(); /** * The last message ID used for an outgoing message */ this.messageId = 1; /** * A queue of tasks used to serialize outgoing messages and to allow the client to buffer outgoing messages when * its not in the 'ready' state. This queue is paused and resumed as the state machine transitions. */ this.outgoingEventQueue = new p_queue_1.default({ concurrency: 1 }); /** * A list of cancelable Promises that each represent a caller waiting on the server to acknowledge an outgoing * message with a response (an incoming message containing a "reply_to" property with the outgoing message's ID). * This list is emptied by canceling all the promises when the client no longer expects to receive any replies from * the server (when its disconnected or when its reconnected and doesn't expect replies for past outgoing messages). * The list is a sparse array, where the indexes are message IDs for the sent messages. */ this.awaitingReplyList = []; this.webClient = webClient || new web_api_1.WebClient(token, { slackApiUrl, logger, logLevel, retryConfig, agent, tls, maxRequestConcurrency: 1, }); this.agentConfig = agent; this.autoReconnect = autoReconnect; this.useRtmConnect = useRtmConnect; this.replyAckOnReconnectTimeout = replyAckOnReconnectTimeout; // NOTE: may want to filter the keys to only those acceptable for TLS options this.tlsConfig = tls !== undefined ? tls : {}; this.keepAlive = new KeepAlive_1.KeepAlive({ clientPingTimeout, serverPongTimeout, logger, logLevel, }); this.keepAlive.on('recommend_reconnect', () => { if (this.websocket !== undefined) { // this will trigger the 'websocket close' event on the state machine, which transitions to clean up this.websocket.close(); // if the websocket actually is no longer connected, the eventual 'websocket close' event will take a long // time, because it won't fire until the close handshake completes. in the meantime, stop the keep alive so we // don't send pings on a dead connection. this.keepAlive.stop(); } }, this); // Logging this.logger = (0, logger_1.getLogger)(RTMClient.loggerName, logLevel, logger); this.stateMachine = finity_1.default.start(this.stateMachineConfig); this.logger.debug('initialized'); } /** * Begin an RTM session using the provided options. This method must be called before any messages can * be sent or received. */ start(options) { this.logger.debug('start()'); // capture options for potential future reconnects this.startOpts = options; // delegate behavior to state machine this.stateMachine.handle('start'); // return a promise that resolves with the connection information return new Promise((resolve, reject) => { this.once('authenticated', (result) => { this.removeListener('disconnected', reject); resolve(result); }); this.once('disconnected', (err) => { this.removeListener('authenticated', resolve); reject(err); }); }); } /** * End an RTM 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('manual disconnect'); // resolve (or reject) on disconnect this.once('disconnected', (err) => { if (err instanceof Error) { reject(err); } else { resolve(); } }); // delegate behavior to state machine this.stateMachine.handle('explicit disconnect'); }); } /** * Send a simple message to a public channel, private channel, DM, or MPDM. * @param text - The message text. * @param conversationId - A conversation ID for the destination of this message. */ async sendMessage(text, conversationId) { return this.addOutgoingEvent(true, 'message', { text, channel: conversationId }); } /** * Sends a typing indicator to indicate that the user with `activeUserId` is typing. * @param conversationId - The destination for where the typing indicator should be shown. */ sendTyping(conversationId) { return this.addOutgoingEvent(false, 'typing', { channel: conversationId }); } /** * Subscribes this client to presence changes for only the given `userIds`. * @param userIds - An array of user IDs whose presence you are interested in. This list will replace the list from * any previous calls to this method. */ subscribePresence(userIds) { return this.addOutgoingEvent(false, 'presence_sub', { ids: userIds }); } addOutgoingEvent(awaitReply, type, body) { // eslint-disable-line max-len const awaitReplyTask = (messageId) => { const replyPromise = new p_cancelable_1.default((resolve, reject, onCancel) => { // We only want the event handler to resolve the Promise in the case the message IDs match // therefore disable consistent-return // eslint-disable-next-line consistent-return const eventHandler = (_type, event) => { if (event.reply_to === messageId) { this.off('slack_event', eventHandler); if (event.error !== undefined) { const error = (0, errors_1.platformErrorFromEvent)(event); return reject(error); } return resolve(event); } }; onCancel(() => { this.off('slack_event', eventHandler); reject((0, errors_1.noReplyReceivedError)()); }); this.on('slack_event', eventHandler); }); this.awaitingReplyList[messageId] = replyPromise; return replyPromise; }; const sendTask = () => { const sendPromise = this.send(type, body); if (awaitReply) { return sendPromise.then(awaitReplyTask); } return sendPromise.then(() => Promise.resolve(undefined)); }; return this.outgoingEventQueue.add(sendTask); } /** * Generic method for sending an outgoing message of an arbitrary type. The main difference between this method and * addOutgoingEvent() is that this method does not use a queue so it can only be used while the client is ready * to send messages (in the 'ready' substate of the 'connected' state). It returns a Promise for the message ID of the * sent message. This is an internal ID and generally shouldn't be used as an identifier for messages (for that, * there is `ts` on messages once the server acknowledges it). * @param type - the message type * @param body - the message body */ send(type, body = {}) { const message = Object.assign(Object.assign({}, body), { type, id: this.nextMessageId() }); return new Promise((resolve, reject) => { this.logger.debug(`send() in state: ${this.stateMachine.getStateHierarchy()}`); if (this.websocket === undefined) { this.logger.error('cannot send message when client is not connected'); reject((0, errors_1.sendWhileDisconnectedError)()); } else if (!(this.stateMachine.getCurrentState() === 'connected' && this.stateMachine.getStateHierarchy()[1] === 'ready')) { this.logger.error('cannot send message when client is not ready'); reject((0, errors_1.sendWhileNotReadyError)()); } else { // NOTE: future feature request: middleware pipeline to process the message before its sent this.emit('outgoing_message', message); const flatMessage = JSON.stringify(message); this.logger.debug(`sending message on websocket: ${flatMessage}`); this.websocket.send(flatMessage, (error) => { // ws success callback uses undefined for Node.js < 19, null for 19+ if (error !== undefined && error !== null) { this.logger.error(`failed to send message on websocket: ${error.message}`); return reject((0, errors_1.websocketErrorWithOriginal)(error)); } return resolve(message.id); }); } }); } /** * Atomically increments and returns a message ID for the next message. */ nextMessageId() { this.messageId += 1; return this.messageId; } /** * Set up method for the client's websocket instance. This method will attach event listeners. */ setupWebsocket(url) { // initialize the websocket const options = Object.assign({ perMessageDeflate: false }, this.tlsConfig); if (this.agentConfig !== undefined) { options.agent = this.agentConfig; } this.websocket = new ws_1.default(url, options); // attach event listeners this.websocket.addEventListener('open', (event) => this.stateMachine.handle('websocket open', event)); this.websocket.addEventListener('close', (event) => this.stateMachine.handle('websocket close', event)); this.websocket.addEventListener('error', (event) => { this.logger.error(`A websocket error occurred: ${event.message}`); this.emit('error', (0, errors_1.websocketErrorWithOriginal)(event.error)); }); this.websocket.on('message', this.onWebsocketMessage.bind(this)); } /** * Tear down method for the client's websocket instance. This method undoes the work in setupWebsocket(url). */ teardownWebsocket() { if (this.websocket !== undefined) { this.websocket.removeAllListeners('open'); this.websocket.removeAllListeners('close'); this.websocket.removeAllListeners('error'); this.websocket.removeAllListeners('message'); this.websocket = undefined; } } /** * `onmessage` handler for the client's websocket. This will parse the payload and dispatch the relevant events for * each incoming message. */ onWebsocketMessage(data) { this.logger.debug(`received message on websocket: ${data.toString()}`); // parse message into slack event let event; try { event = JSON.parse(data.toString()); // biome-ignore lint/suspicious/noExplicitAny: errors can be anything } catch (parseError) { // prevent application from crashing on a bad message, but log an error to bring attention this.logger.error(`unable to parse incoming websocket message: ${parseError.message}\n message contents: "${data}"`); return; } // internal event handlers if (event.type === 'hello') { this.stateMachine.handle('server hello'); } if (event.type === 'team_migration_started') { if (this.websocket !== undefined) { // this will trigger the 'websocket close' event on the state machine, which transitions to clean up this.websocket.close(); } } if (this.stateMachine.getCurrentState() === 'connected' && this.stateMachine.getStateHierarchy()[1] === 'resuming' && event.reply_to !== undefined && event.reply_to === this.messageId) { this.stateMachine.handle('replay finished'); } // emit for event handlers this.emit('slack_event', event.type, event); this.emit(event.type, event); if (event.subtype !== undefined) { this.emit(`${event.type}::${event.subtype}`, event); } } } exports.RTMClient = RTMClient; /** * The name used to prefix all logging generated from this object */ RTMClient.loggerName = 'RTMClient'; /* Instrumentation */ (0, web_api_1.addAppMetadata)({ name: packageJson.name, version: packageJson.version }); exports.default = RTMClient; //# sourceMappingURL=RTMClient.js.map