UNPKG

@slack/client

Version:
888 lines (742 loc) 25.2 kB
/** * See [the RTM client events](../events/client) for details of the client event lifecycle. */ var Promise = require('bluebird'); var bind = require('lodash').bind; var cloneDeep = require('lodash').cloneDeep; var includes = require('lodash').includes; var hasKey = require('lodash').has; var inherits = require('inherits'); var isFunction = require('lodash').isFunction; var isUndefined = require('lodash').isUndefined; var forEach = require('lodash').forEach; var noop = require('lodash').noop; var keys = require('lodash').keys; var RTM_API_EVENTS = require('../events/rtm').EVENTS; var RTM_CLIENT_INTERNAL_EVENT_TYPES = [ 'pong', RTM_API_EVENTS.HELLO, RTM_API_EVENTS.TEAM_MIGRATION_STARTED ]; var UNRECOVERABLE_RTM_START_ERRS = [ 'not_authed', 'invalid_auth', 'account_inactive', 'user_removed_from_team', 'team_disabled' ]; var CLIENT_EVENTS = require('../events/client').RTM; var BaseAPIClient = require('../client'); var DataStore = require('../../data-store/data-store'); var MemoryDataStore = require('../../data-store/memory-data-store'); var RtmFacet = require('../web/facets').RtmFacet; var ChatFacet = require('../web/facets').ChatFacet; var SlackRTMSendError = require('../errors').SlackRTMSendError; var makeMessageEventWithSubtype = require('../events/utils').makeMessageEventWithSubtype; var wsSocketFn = require('../transports/ws'); var retryPolicyFromOptions = require('../retry-policies').retryPolicyFromOptions; /** * Creates a new instance of RTM client. * @param {string} token - The token to use for connecting * @param {Object} [opts] * @param {RTMClient~socketFn} [opts.socketFn] - A function to call, passing in a websocket URL, * that should return a websocket instance connected to that URL * @param {SlackDataStore|null|false} [opts.dataStore] - A store to cache Slack info. Recommended * value is `false`. Default value is an instance of {@link SlackMemoryDataStore}. * @param {boolean} [opts.autoReconnect=true] - Whether or not to automatically reconnect when the * connection closes * @param {boolean} [opts.useRtmConnect=false] - Whether to use rtm.connect rather than rtm.start. * Recommended value is `true`. * @param {Object} [opts.retryConfig] - The retry policy to use, defaults to forever with * exponential backoff, see {@link https://github.com/SEAPUNK/node-retry|node-retry} for more details. * @param {number} [opts.maxReconnectionAttempts] - DEPRECATED: Use retryConfig instead * @param {number} [opts.reconnectionBackoff] - DEPRECATED: Use retryConfig instead * @param {number} [opts.wsPingInterval=5000] - The time (in ms) to wait between pings with the * server * @param {number} [opts.maxPongInterval] - The max time (in ms) to wait for a pong before * reconnecting * @param {string} [opts.logLevel=info] - The log level for the logger * @param {RTMClient~logFn} [opts.logger] - Function to use for log calls, takes * (logLevel, logString) parameters * @constructor */ function RTMClient(token, opts) { var clientOpts = opts || {}; // Force the max request concurrency to 1 for the RTM client. This is because it should not be // trying to rtm.start in parallel. clientOpts.maxRequestConcurrency = 1; // Migrate deprecated parameters to a retry policy. if (!clientOpts.retryConfig) { clientOpts.retryConfig = retryPolicyFromOptions(clientOpts); } BaseAPIClient.call(this, token, clientOpts); /** * @type {Function} */ this._socketFn = clientOpts.socketFn || wsSocketFn.factory(this.logger); /** * The active websocket. * @type {Object} */ this.ws = undefined; /** * * @type {number} * @private */ this._messageId = 1; /** * @type {{}} * @private */ this._pendingMessages = {}; /** * * @type {{}} * @private */ this._msgResponseHandlers = {}; /** * * @type {{}} * @private */ this._msgChannelLookup = {}; /** * If true, we'll use rtm.connect everywhere in place of rtm.start * @type {boolean} * @private */ this._useRtmConnect = clientOpts.useRtmConnect; if (clientOpts.dataStore instanceof DataStore) { this.registerDataStore(clientOpts.dataStore); } else { // Default to using the memory data store if the user didn't set anything here if (isUndefined(clientOpts.dataStore)) { this.registerDataStore(new MemoryDataStore()); } } // See the "Ping and Pong" section of https://api.slack.com/rtm // These are to do with the RTM API level connection and not the underlying // socket connection. this.MAX_PONG_INTERVAL = clientOpts.maxPongInterval || 20000; this.WS_PING_INTERVAL = clientOpts.wsPingInterval || 5000; this.autoReconnect = clientOpts.autoReconnect !== false; this.logger('debug', 'RTMClient initialized'); } inherits(RTMClient, BaseAPIClient); /** * @type {boolean} */ RTMClient.prototype.connected = false; /** * @type {boolean} */ RTMClient.prototype.authenticated = false; /** * The id of the user that's currently connected via this client. * @type {string} */ RTMClient.prototype.activeUserId = undefined; /** * The id of the team that's currently connected via this client. * @type {string} */ RTMClient.prototype.activeTeamId = undefined; /** * @type {?SlackDataStore} */ RTMClient.prototype.dataStore = undefined; /** * The timer repeatedly pinging the server to let it know the client is still alive. * @type {?} */ RTMClient.prototype._pingTimer = null; /** * The time the last pong was received from the server. * @type {number} * @private */ RTMClient.prototype._lastPong = 0; /** * A running count of socket connection attempts. * @type {number} * @private */ RTMClient.prototype._connAttempts = 0; /** * Whether the server is currently connecting. * @type {boolean} * @private */ RTMClient.prototype._connecting = false; /** * Options passed to `start`, for use when reconnecting. * @type {object} * @private */ RTMClient.prototype._startOpts = null; /** * Whether the server is currently reconnecting. * @type {boolean} * @private */ RTMClient.prototype._reconnecting = false; /** @inheritDoc */ RTMClient.prototype._createFacets = function _createFacets() { RTMClient.super_.prototype._createFacets.call(this); /** * Allows the RTM client to get setup without needing to have a full web client. * @type {RtmFacet} * @private */ this._rtm = new RtmFacet(bind(this._makeAPICall, this)); /** * Allows access to some chat functions * @type {ChatFacet} * @private */ this._chat = new ChatFacet(bind(this._makeAPICall, this)); }; /** * Begin an RTM session. * @param {object} opts */ RTMClient.prototype.start = function start(opts) { // Check whether the client is currently attempting to connect to the RTM API. if (!this._connecting) { this.logger('verbose', 'Attempting to connect via the RTM API'); this.emit(CLIENT_EVENTS.CONNECTING); this._connecting = true; this._startOpts = opts; if (this._useRtmConnect) { this._rtm.connect(opts, bind(this._onStart, this)); } else { this._rtm.start(opts, bind(this._onStart, this)); } } }; /** * @deprecated since version 2.0.0, use start() instead. */ RTMClient.prototype.login = function login(opts) { this.start(opts); }; /** * Generates the next message id to use. * * NOTE: This id must be unique per RTM connection. */ RTMClient.prototype.nextMessageId = function nextMessageId() { return this._messageId++; }; /** * Occurs when we've received a response to the API call made in start. * Connect to the socket using the URL from the response, or if it went wrong, * check the error and potentially retry or permanently disconnect. * @param requestError - An error that occurred during the request * @param data - The response data * @private */ RTMClient.prototype._onStart = function _onStart(requestError, data) { // There may have been an HTTP error or an error returned from the Slack API var error = requestError || data.error; var startMethod = this._useRtmConnect ? 'rtm.connect' : 'rtm.start'; var disconnectWithReason = bind(function disconnectWithReason(reason) { this.logger('error', 'Disconnecting because ' + reason); this.logger('error', error); this.disconnect(reason, error); }, this); this._connecting = false; this._reconnecting = false; if (error || (!data || !data.url)) { this.emit(CLIENT_EVENTS.UNABLE_TO_RTM_START, error); // Any of these mean this client is unusable, so don't attempt to auto-reconnect if (data && includes(UNRECOVERABLE_RTM_START_ERRS, data.error)) { disconnectWithReason(data.error + ' is not recoverable'); } else { this.authenticated = false; if (this.autoReconnect) { this.logger('info', 'Unable to ' + startMethod + ', attempting reconnect'); this.reconnect(); } else { disconnectWithReason('auto-reconnect is disabled'); } } } else { this.logger('verbose', startMethod + ' successful, attempting to open websocket URL'); this.authenticated = true; this.activeUserId = data.self.id; this.activeTeamId = data.team.id; if (!isUndefined(this.dataStore)) { this.dataStore.cacheRtmStart(data); } this.emit(CLIENT_EVENTS.AUTHENTICATED, data); this.connect(data.url); } }; /** * Closes the websocket and tears down the ping function. * @private */ RTMClient.prototype._safeDisconnect = function _safeDisconnect() { if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; } if (this.ws) { // Stop listening to the websocket's close event, so that the auto-reconnect logic doesn't fire this.ws.removeAllListeners('close'); this.ws.close(); } this.authenticated = false; this.connected = false; }; /** * Connects to the RTM API. * @param {String} socketUrl The URL of the websocket to connect to. */ RTMClient.prototype.connect = function connect(socketUrl) { this.emit(CLIENT_EVENTS.WS_OPENING); this.ws = this._socketFn(socketUrl); this.ws.on('open', bind(this.handleWsOpen, this)); this.ws.on('message', bind(this.handleWsMessage, this)); this.ws.on('error', bind(this.handleWsError, this)); this.ws.on('close', bind(this.handleWsClose, this)); }; /** * Disconnects from the RTM API. * @param {Error} optReason * @param {Number} optCode */ RTMClient.prototype.disconnect = function disconnect(optErr, optCode) { this.emit(CLIENT_EVENTS.DISCONNECT, optErr, optCode); this.autoReconnect = false; this._safeDisconnect(); }; /** * Attempts to reconnect to the websocket by retrying the start method. */ RTMClient.prototype.reconnect = function reconnect() { if (!this._reconnecting) { this.emit(CLIENT_EVENTS.ATTEMPTING_RECONNECT); this._reconnecting = true; this._safeDisconnect(); this._connAttempts++; this.logger('warn', 'Reconnecting, on attempt', this._connAttempts); // Ensure we use the same arguments to `start` when reconnecting this.start(this._startOpts); } }; /** * Handler to deal with the WebSocket open event. * NOTE: this.connected doesn't get set to true until the helloHandler is called. */ RTMClient.prototype.handleWsOpen = function handleWsOpen() { this.emit(CLIENT_EVENTS.WS_OPENED); }; /** * Handler to deal with the WebSocket message event. * @param {object} wsMsg */ RTMClient.prototype.handleWsMessage = function handleWsMessage(wsMsg) { var message; this.logger('debug', wsMsg); this.emit(CLIENT_EVENTS.RAW_MESSAGE, wsMsg); try { message = JSON.parse(wsMsg); } catch (err) { this.logger('error', 'Unable to parse message: ' + err); return; } if (includes(RTM_CLIENT_INTERNAL_EVENT_TYPES, message.type)) { this._handleWsMessageInternal(message.type, message); } else { this._maybeKeepAlive(message); this._handleWsMessageViaEventHandler(message.type, message); } }; /** * Handler for messages we need to deal with internally. * @param {String} messageType * @param {Object} message * @private */ RTMClient.prototype._handleWsMessageInternal = function _handleWsMessageInternal( messageType, message) { if (messageType === 'pong') { this._handlePong(message); } else if (messageType === RTM_API_EVENTS.HELLO) { this._handleHello(); } else if (messageType === RTM_API_EVENTS.TEAM_MIGRATION_STARTED) { this.reconnect(); this.emit(messageType, message); } }; /** * * @param {String} messageType * @param {Object} message * @private */ RTMClient.prototype._handleWsMessageViaEventHandler = function _handleWsMessageViaEventHandler( messageType, message) { var replyTo = message.reply_to; if (!isUndefined(this.dataStore)) { this.dataStore.handleRtmMessage(this.activeUserId, this.activeTeamId, messageType, message); } if (replyTo) { if (!messageType) { this._handleMessageAck(replyTo, message); } else { this._handleMostRecentMsgReply(replyTo, message); } } else { // Non reply_to messages should *always* have a type this.emit(messageType, message); if (messageType === RTM_API_EVENTS.MESSAGE && !isUndefined(message.subtype)) { this.emit(makeMessageEventWithSubtype(message.subtype), message); } } }; /** * Handler for the remote server's response to a message being sent on the websocket. * @param replyTo * @param message */ RTMClient.prototype._handleMessageAck = function handleMessageAck(replyTo, message) { var msg; var channelId; // This means that the message is an ack of a message sent via the websocket from the client. // The expected structure is something like: // { // "ok": true, // "reply_to": 1, // "ts": "1355517523.000005", // "text": "Hello world" // } // ... in the case of success. // // See the Sending messages section of https://api.slack.com/rtm for details. if (hasKey(this._msgResponseHandlers, replyTo)) { if (!message.ok) { this._handleMsgResponse(replyTo, new SlackRTMSendError(message.error.msg, message), null); } else { channelId = this._msgChannelLookup[replyTo]; // Make a synthetic message, rather than passing back the raw ack response msg = { type: 'message', channel: channelId, user: this.activeUserId, text: message.text, ts: message.ts }; if (this.dataStore) { // NOTE: this will treat the message response as canonical and assumes that no temporary // message has been put in place. this.dataStore.handleRtmMessage(this.activeUserId, this.activeTeamId, 'message', msg); } this._handleMsgResponse(replyTo, null, msg); } } else { // This should be impossible. If the message is received with no message type, it's a response // to a message sent by this client, so the absence of a handler for it should never happen. this.logger('error', 'message received with unknown reply_to: ' + JSON.stringify(message)); } }; /** * Handler for the server * @param {} replyTo * @param {{}} message * @private */ RTMClient.prototype._handleMostRecentMsgReply = function _handleMostRecentMsgReply( replyTo, message) { var msg; var pendingMessageIds; var failedToSendErr; // The server will send the most recent reply message to the client when it first connects, // so will receive a message that looks like: // // { // "reply_to": 848, // "type": "message", // "channel": "C0CHZA86Q", // "user": "U0CJ5PC7L", // "text": "meh-mah", // "ts": "1457327357.000011" // } // // This should generally only happen in the case of disconnections. If that happens, the last // message should be handled gracefully. if (this._msgResponseHandlers[replyTo]) { msg = cloneDeep(message); delete msg.reply_to; this._handleMsgResponse(replyTo, null, msg); } else { // The only time this should happen is when a client first connects this.logger('info', 'message received on reconnect with no registered callback:\n' + JSON.stringify(message)); } pendingMessageIds = keys(this._pendingMessages); forEach(pendingMessageIds, bind(function handlePendingMessage(messageId) { // If the message id is less than the most recent reply to id, assume that the message has // been processed. If it's greater than the most recent reply to, then it's likely that the // message didn't get sent to the remote server. This should almost never happen, so for now if // it does, call the pending callback with a custom error if (messageId > replyTo) { failedToSendErr = new SlackRTMSendError( 'message not sent due to connection trouble', this._pendingMessages[messageId]); this._handleMsgResponse(messageId, failedToSendErr, null); } else { this._clearMessageState(messageId); } }, this)); }; /** * Emits the websocket error. * @param {Object} err */ RTMClient.prototype.handleWsError = function handleWsError(err) { this.logger('debug', err); this.emit(CLIENT_EVENTS.WS_ERROR, err); }; /** * Occurs when the websocket closes. * @param {String} code The error code * @param {String} reason The reason for closing */ RTMClient.prototype.handleWsClose = function handleWsClose(code, reason) { this.connected = false; this.emit(CLIENT_EVENTS.WS_CLOSE, code, reason); if (this.autoReconnect) { if (!this._connecting) { this.reconnect(); } } else { this.disconnect('websocket closed with auto-reconnect false on the RTM client'); } }; /** * Handles the RTM API's pong message, updating the lastPong time on the client. * @param {Object} message */ RTMClient.prototype._handlePong = function _handlePong(message) { this._lastPong = Date.now(); this._handleMsgResponse(message.reply_to, null, message); }; /** * Pings the remote server to let it know the client is still alive. * @private */ RTMClient.prototype._pingServer = function _pingServer() { var pongInterval; if (this.connected) { // Get the delta between ping sent & pong received, remember this timer // fires sometime later so deduct that to get the real latency pongInterval = Math.abs(Date.now() - this._lastPong - this.WS_PING_INTERVAL); this.logger('debug', 'waited ' + pongInterval + ' ms for a pong'); // If we didn't receive a response to the last pong in some duration, // force a reconnect if (pongInterval > this.MAX_PONG_INTERVAL) { this.reconnect(); } else { this.send({ type: 'ping' }, noop); } } }; /** * If we haven't received a pong in too long, treat any incoming message as a pong * to prevent unnecessary disconnects. * @param {Object} message */ RTMClient.prototype._maybeKeepAlive = function _maybeKeepAlive(message) { var pongInterval = Date.now() - this._lastPong; if (pongInterval > this.MAX_PONG_INTERVAL) { this.logger('warn', 'No pong in ' + pongInterval + 'ms, treating ' + message.type + ' as keep-alive'); this._lastPong = Date.now(); } }; /** * Occurs when the socket connection is opened. * Begin ping-pong with the server. * {@link https://api.slack.com/events/hello|hello} */ RTMClient.prototype._handleHello = function _handleHello() { this.connected = true; this.emit(CLIENT_EVENTS.RTM_CONNECTION_OPENED); this._lastPong = Date.now(); this._connAttempts = 0; if (this._pingTimer) clearInterval(this._pingTimer); this._pingTimer = setInterval(bind(this._pingServer, this), this.WS_PING_INTERVAL); }; /** * Helper for sending a simple message to a channel|group|DM etc via the RTM API. * @param {string} text The text of the messsage to send. * @param {string} channelId The id of the channel|group|DM to send this message to. * @param {Function=} optCb */ RTMClient.prototype.sendMessage = function sendMessage(text, channelId, optCb) { return this.send({ text: text, channel: channelId, type: RTM_API_EVENTS.MESSAGE }, optCb); }; /** * Helper for updating a sent message via the 'chat.update' API call * @param message {object} message The message object to be updated, * see /lib/clients/web/facets/chat.js for more details * @param {Function=} optCb Optional callback */ RTMClient.prototype.updateMessage = function updateMessage(message, optCb) { return this._chat.update(message.ts, message.channel, message.text, message.opts, optCb); }; /** * Sends a typing indicator to indicate that the user with `activeUserId` is typing. * @param {string} channelId The id of the channel|group|DM to send this message to. */ RTMClient.prototype.sendTyping = function sendTyping(channelId) { this.send({ type: 'typing', channel: channelId }, noop); }; /** * Subscribes this socket to presence changes for only the given `userIds`. * This requires `presence_sub` to have been passed as an argument to `start`. * @param {Array} userIds The user IDs to subscribe to */ RTMClient.prototype.subscribePresence = function subscribePresence(userIds) { this.send({ type: 'presence_sub', ids: userIds }, noop); }; /** * Sends a message over the websocket to the server. * @param {*} message The message to send back to the server. * @param {Function=} optCb */ RTMClient.prototype.send = function send(message, optCb) { var err; var ret; if (this.connected) { ret = this._send(message, optCb); } else { err = new SlackRTMSendError('ws not connected, unable to send message', message); this.logger('error', err); if (optCb) { setTimeout(function wsSendDisconnectedCb() { optCb(err, null); }, 1); } else { ret = new Promise(function wsSendDisconnectedPromise(fulfill, reject) { reject(err); }); } } return ret; }; /** * * @param message * @param optCb * @returns {*} * @private */ RTMClient.prototype._send = function _send(message, optCb) { var _this = this; var jsonMessage; var ret; var msgId = this.nextMessageId(); var _handleWsSendResponse = bind(this._handleWsSendResponse, this, msgId); var wsMsg = cloneDeep(message); wsMsg.id = msgId; // Track the message from the client's perspective this._pendingMessages[msgId] = message; jsonMessage = JSON.stringify(wsMsg); this.logger('debug', 'sending message via ws: ' + jsonMessage); if (optCb) { _this._registerMsgHandler(msgId, wsMsg, optCb); this.ws.send(jsonMessage, undefined, _handleWsSendResponse); } else { ret = new Promise(function wsSendPromiseInner(fulfill, reject) { _this._registerMsgHandler(msgId, wsMsg, { fulfill: fulfill, reject: reject }); _this.ws.send(jsonMessage, undefined, _handleWsSendResponse); }); } return ret; }; /** * * @param msgId * @param wsRespErr * @private */ RTMClient.prototype._handleWsSendResponse = function _handleWsSendResponse(msgId, wsRespErr) { if (wsRespErr) { this._handleMsgResponse(msgId, wsRespErr, null); } else { // TODO(leah): This should probably clear the enqueued message object from _pendingMessages, as // the server has the data at this point. Figure out how this will interact with // the logic in _handleMostRecentMsgReply } }; /** * Registers a handler, either a function or {fulfill: fn, reject: fn}, for a message id. * @param {number} msgId The id of the message to handle. * @param {object} wsMsg * @param {*} handler * @private */ RTMClient.prototype._registerMsgHandler = function _registerMsgHandler(msgId, wsMsg, handler) { // // Track the channel a message was sent to. // // This is so that the client can create a synthetic message object when it receives the message // ack from the RTM API server. This allows: // - passing back a full message object, rather than an ack message, to the client that sent the // message // - pushing a standard message into the relevant channel's history // if (wsMsg.type === 'message' && wsMsg.channel) { this._msgChannelLookup[msgId] = wsMsg.channel; } this._msgResponseHandlers[msgId] = handler; }; /** * Calls the handler registered for a given msgId and cleans it up on completion. * * @param {number} msgId The id of the message to handle. * @param {Object} err * @param {Object} res * @private */ RTMClient.prototype._handleMsgResponse = function _handleMsgResponse(msgId, err, res) { var responseHandler = this._msgResponseHandlers[msgId]; if (err) { this.logger('debug', 'Error sending message: ' + err); } if (!responseHandler) { this.logger('debug', 'no response handler for message ID: ' + msgId); return; } if (isFunction(responseHandler)) { responseHandler(err, res); } else { if (err) { responseHandler.reject(err); } else { responseHandler.fulfill(res); } } this._clearMessageState(msgId); }; RTMClient.prototype._clearMessageState = function _clearMessageState(msgId) { delete this._msgChannelLookup[msgId]; delete this._msgResponseHandlers[msgId]; delete this._pendingMessages[msgId]; }; module.exports = RTMClient;