UNPKG

bot18

Version:

A high-frequency cryptocurrency trading bot by Zenbot creator @carlos8f

498 lines 24.3 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const objectValues = require("object.values"); // tslint:disable-line:import-name no-require-imports const EventEmitter = require("eventemitter3"); // tslint:disable-line:import-name no-require-imports const WebSocket = require("ws"); // tslint:disable-line:import-name no-require-imports const finity_1 = require("finity"); // tslint:disable-line:import-name const PQueue = require("p-queue"); // tslint:disable-line:import-name no-require-imports const PCancelable = require("p-cancelable"); // tslint:disable-line:import-name no-require-imports const logger_1 = require("./logger"); const KeepAlive_1 = require("./KeepAlive"); const _1 = require("./"); const errors_1 = require("./errors"); const util_1 = require("./util"); const pkg = require('../package.json'); // tslint:disable-line:no-require-imports no-var-requires /** * 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 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, } = {}) { 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; /** * Configuration for the state machine */ this.stateMachineConfig = finity_1.default .configure() .initialState('disconnected') .on('start').transitionTo('connecting') .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 this.awaitingReplyList.forEach(p => 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 ? this.startOpts : {}) .then((result) => { // SEMVER:MAJOR: no longer handling the case where `result.url` is undefined separately from an error. // cannot think of a way this would have been triggered. // capture identity information // TODO: remove type casts this.activeUserId = result.self.id; this.activeTeamId = result.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}`); // v3 legacy event this.emit('unable_to_rtm_start', error); // NOTE: assume that ReadErrors are recoverable let isRecoverable = true; if (error.code === _1.ErrorCode.PlatformError && objectValues(UnrecoverableRTMStartError).includes(error.data.error)) { isRecoverable = false; } else if (error.code === _1.ErrorCode.RequestError) { isRecoverable = false; } else if (error.code === _1.ErrorCode.HTTPError) { isRecoverable = false; } return this.autoReconnect && isRecoverable; }) .ignore().withAction(() => { // dispatch 'failure' on parent machine to transition out of this submachine's states this.stateMachine.handle('failure'); }) .state('authenticated') .onEnter((_state, context) => { this.authenticated = true; this.setupWebsocket(context.result.url); setImmediate(() => { this.emit('authenticated', context.result); }); }) .on('websocket open').transitionTo('handshaking') .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(); }) .state('handshaking') // a state in which to wait until the 'server hello' event .on('websocket close') .transitionTo('reconnecting').withCondition(() => this.autoReconnect) .withAction((_from, _to, context) => { this.logger.debug(`reconnecting after unexpected close ${context.eventPayload.reason} ` + `${context.eventPayload.code} with isMonitoring set to ${this.keepAlive.isMonitoring} ` + `and recommendReconnect set to ${this.keepAlive.recommendReconnect}`); }) .transitionTo('disconnected') .withAction((_from, _to, context) => { this.logger.debug(`disconnected after unexpected close ${context.eventPayload.reason} ` + `${context.eventPayload.code} with isMonitoring set to ${this.keepAlive.isMonitoring} ` + `and recommendReconnect set to ${this.keepAlive.recommendReconnect}`); // this transition circumvents the 'disconnecting' state (since the websocket is already closed), // so we need to execute its onExit behavior here. this.teardownWebsocket(); }) .global() .onStateEnter((state) => { this.logger.debug(`transitioning to state: connecting:${state}`); }) .getConfig()) .on('server hello').transitionTo('connected') .on('failure').transitionTo('disconnected') .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. this.awaitingReplyList.forEach(p => 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 = this.activeTeamId = undefined; this.keepAlive.stop(); this.outgoingEventQueue.pause(); }) .state('disconnecting') .onEnter(() => { // invariant: websocket exists and is open at the start of this state if (this.websocket !== undefined) { this.websocket.close(); } else { this.logger.error('Websocket not found when transitioning into disconnecting state. Please report to ' + '@slack/client package maintainers.'); } }) .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) => { this.logger.debug(`transitioning to state: ${state}`); // Emits events: `disconnected`, `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 PQueue({ 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 = new _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(); } }, this); // Logging if (logger !== undefined) { this.logger = logger_1.loggerFromLoggingFunc(RTMClient.loggerName, logger); } else { this.logger = logger_1.getLogger(RTMClient.loggerName); } this.logger.setLevel(logLevel); 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. * @param options */ start(options) { // TODO: should this return a Promise<WebAPICallResult>? // TODO: make a named interface for the type of `options`. it should end in -Options instead of Arguments. this.logger.debug('start()'); // capture options for potential future reconnects this.startOpts = options; // delegate behavior to state machine this.stateMachine.handle('start'); } /** * End an RTM session. After this method is called no messages will be sent or received unless you call * start() again later. */ disconnect() { // TODO: should this return a Promise<void>? this.logger.debug('manual disconnect'); // delegate behavior to state machine this.stateMachine.handle('explicit disconnect'); } sendMessage(text, conversationId, callback) { const implementation = () => this.addOutgoingEvent(true, 'message', { text, channel: conversationId }); // Adapt the interface for callback-based execution or Promise-based execution if (callback !== undefined) { util_1.callbackify(implementation)(callback); return; } return implementation(); } /** * 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) { // SEMVER:MINOR now returns a Promise, where it used to return void // NOTE: should we allow for callback-based execution of this method? 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) { // SEMVER:MINOR now returns a Promise, where it used to return void // NOTE: should we allow for callback-based execution of this method? return this.addOutgoingEvent(false, 'presence_sub', { ids: userIds }); } addOutgoingEvent(awaitReply, type, body) { const awaitReplyTask = (messageId) => { const replyPromise = new PCancelable((onCancel, resolve, reject) => { const eventHandler = (_type, event) => { if (event.reply_to === messageId) { this.off('slack_event', eventHandler); if (event.error !== undefined) { const error = errors_1.errorWithCode(new Error(`An API error occurred: ${event.error.msg}`), _1.ErrorCode.RTMSendMessagePlatformError); error.data = event; return reject(error); } resolve(event); } }; onCancel(() => { this.off('slack_event', eventHandler); reject(errors_1.errorWithCode(new Error('Message sent but no server acknowledgement was recieved. This may be caused by the client ' + 'changing connection state rather than any issue with the specific message. Check before resending.'), _1.ErrorCode.RTMNoReplyReceivedError)); }); 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()); }; 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({}, 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(errors_1.errorWithCode(new Error('cannot send message when client is not connected'), _1.ErrorCode.RTMSendWhileDisconnectedError)); } else if (!(this.stateMachine.getCurrentState() === 'connected' && this.stateMachine.getStateHierarchy()[1] === 'ready')) { this.logger.error('cannot send message when client is not ready'); reject(errors_1.errorWithCode(new Error('cannot send message when client is not ready'), _1.ErrorCode.RTMSendWhileNotReadyError)); } 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) => { if (error !== undefined) { this.logger.error(`failed to send message on websocket: ${error.message}`); return reject(websocketErrorWithOriginal(error)); } resolve(message.id); }); } }); } /** * Atomically increments and returns a message ID for the next message. */ nextMessageId() { return this.messageId++; // tslint:disable-line:no-increment-decrement } /** * Set up method for the client's websocket instance. This method will attach event listeners. * @param url */ setupWebsocket(url) { // initialize the websocket const options = Object.assign({ perMessageDeflate: false, }, this.tlsConfig); if (this.agentConfig !== undefined) { options.agent = this.agentConfig; } this.websocket = new WebSocket(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', websocketErrorWithOriginal(event.error)); }); this.websocket.addEventListener('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. * @param websocketMessage */ onWebsocketMessage({ data }) { // v3 legacy this.emit('raw_message', data); this.logger.debug(`received message on websocket: ${data}`); // parse message into slack event let event; try { event = JSON.parse(data); } 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); } } } /** * The name used to prefix all logging generated from this object */ RTMClient.loggerName = `${pkg.name}:RTMClient`; exports.RTMClient = RTMClient; exports.default = RTMClient; /* * Helpers */ /** * A factory to create RTMWebsocketError objects. * @param original */ function websocketErrorWithOriginal(original) { const error = errors_1.errorWithCode(new Error(`Failed to send message on websocket: ${original.message}`), _1.ErrorCode.RTMWebsocketError); error.original = original; return error; } // NOTE: there may be a better way to add metadata to an error about being "unrecoverable" than to keep an // independent enum 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 = {})); //# sourceMappingURL=RTMClient.js.map