diff --git a/.gitignore b/.gitignore index efa56f020..e6a449035 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ examples/package-lock.json # nyc test coverage .nyc_output +package-lock.json diff --git a/.npmignore b/.npmignore deleted file mode 100644 index 286347914..000000000 --- a/.npmignore +++ /dev/null @@ -1,19 +0,0 @@ -# Logs -logs -*.log -npm-debug.log* - -# Directory for instrumented libs generated by jscoverage/JSCover -lib-cov - -# Coverage directory used by tools like istanbul -coverage - -# nyc test coverage -.nyc_output - -spec -.git -.vscode - -test/ diff --git a/.travis.yml b/.travis.yml index 3b74c0808..a1b2b154a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,5 +4,6 @@ node_js: - "7" - "8" - "9" + - "10" after_success: - npm run coverage diff --git a/docs/_pages/rtm_client.md b/docs/_pages/rtm_client.md index 591937dd2..67b05f1ca 100644 --- a/docs/_pages/rtm_client.md +++ b/docs/_pages/rtm_client.md @@ -310,6 +310,8 @@ export. You can also capture the logs without writing them to stdout by setting the `logger` option. It should be set to a function that takes `fn(level: string, message: string)`. +**Note** `logLevel: LogLevel.DEBUG` should not be used in production. Debug is helpful for diagnosing issues but it is a bad idea to use this in production because it will log the contents of messages in the RTMClient. + ```javascript const fs = require('fs'); const { RTMClient, LogLevel } = require('@slack/client'); diff --git a/docs/_pages/web_client.md b/docs/_pages/web_client.md index 527151e58..4e0ceb653 100644 --- a/docs/_pages/web_client.md +++ b/docs/_pages/web_client.md @@ -9,10 +9,12 @@ headings: - title: Adding attachments to a message - title: Uploading a file - title: Getting a list of channels + - title: Calling methods on behalf of users - title: Using a callback instead of a Promise - title: Changing the retry configuration - title: Changing the request concurrency - title: Rate limit handling + - title: Pagination - title: Customizing the logger - title: Custom agent for proxy support - title: OAuth token exchange @@ -129,7 +131,7 @@ const web = new WebClient(token); // This file is located in the current directory (`process.pwd()`) const filename = 'test_file.csv'; -// See: https://api.slack.com/methods/chat.postMessage +// See: https://api.slack.com/methods/files.upload web.files.upload({ filename, // You can use a ReadableStream or a Buffer for the file option @@ -167,7 +169,34 @@ web.channels.list() res.channels.forEach(c => console.log(c.name)); }) .catch(console.error); -}); +``` + +--- + +### Calling methods on behalf of users + +When using [workspace tokens](https://api.slack.com/docs/working-with-workspace-tokens), some methods allow your app +to perform the action [on behalf of a user](https://api.slack.com/docs/working-for-users). To use one of these methods, +your app will provide the user's ID as an option named `on_behalf_of`. + +```javascript +const { WebClient } = require('@slack/client'); + +// An access token (from your Slack workspace app - xoxa) +const token = process.env.SLACK_TOKEN; + +// A user ID - this may be found in events or requests such as slash commands, interactive messages, actions, or dialogs +const userId = 'U0123456'; + +const web = new WebClient(token); + +// https://api.slack.com/methods/users.identity +web.users.identity({ on_behalf_of: userId }) + .then((res) => { + // `res` contains information about the user. the specific structure depends on the scopes your app was allowed. + console.log(res); + }) + .catch(console.error); ``` --- @@ -231,17 +260,118 @@ const web = new WebClient(token, { ### Rate limit handling -When your application has exceeded the [rate limit](https://api.slack.com/docs/rate-limits#web) for a certain method, -the `WebClient` object will emit a `rate_limited` event. Observing this event can be useful for scheduling work to be -done in the future. +Typically, you shouldn't have to worry about rate limits. By default, the `WebClient` will automatically wait the +appropriate amount of time and retry the request. During that time, all new requests from the `WebClient` will be +paused, so it doesn't make your rate-limiting problem worse. Then, once a successful response is received, the returned +Promise is resolved with the result. + +In addition, you can observe when your application has been rate-limited by attaching a handler to the `rate_limited` +event. ```javascript const { WebClient } = require('@slack/client'); const token = process.env.SLACK_TOKEN; const web = new WebClient(token); -web.on('rate_limited', retryAfter => console.log(`Delay future requests by at least ${retryAfter} seconds`)); +web.on('rate_limited', (retryAfter) => { + console.log(`A request was rate limited and future requests will be paused for ${retryAfter} seconds`); +}); + +const userIds = []; // a potentially long list of user IDs +for (user of userIds) { + // if this list is large enough and responses are fast enough, this might trigger a rate-limit + // but you will get each result without any additional code, since the rate-limited requests will be retried + web.users.info({ user }).then(console.log).catch(console.error); +} ``` +If you'd like to handle rate-limits in a specific way for your application, you can turn off the automatic retrying of +rate-limited API calls with the `rejectRateLimitedCalls` configuration option. + +```javascript +const { WebClient, ErrorCode } = require('@slack/client'); +const token = process.env.SLACK_TOKEN; +const web = new WebClient(token, { rejectRateLimitedCalls: true }); + +const userIds = []; // a potentially long list of user IDs +for (user of userIds) { + web.users.info({ user }).then(console.log).catch((error) => { + if (error.code === ErrorCodes.RateLimitedError) { + // the request was rate-limited, you can deal with this error in your application however you wish + console.log( + `The users.info with ID ${user} failed due to rate limiting. ` + + `The request can be retried in ${error.retryAfter} seconds.` + ); + } else { + // some other error occurred + console.error(error.message); + } + }); +} +``` + +--- + +### Pagination + +Some methods are meant to return large lists of things; whether it be users, channels, messages, or something else. In +order to efficiently get you the data you need, Slack will return parts of that entire list, or **pages**. Cursor-based +pagination describes using a couple options: `cursor` and `limit` to get exactly the page of data you desire. For +example, this is how your app would get the last 500 messages in a conversation. + +```javascript +const { WebClient } = require('@slack/client'); +const token = process.env.SLACK_TOKEN; +const web = new WebClient(token); +const conversationId = 'C123456'; // some conversation ID + +web.conversations.history({ channel: conversationId, limit: 500 }) + .then((res) => { + console.log(`Requested 500 messages, recieved ${res.messages.length} in the response`); + }) + .catch(console.error); +``` + +In the code above, the `res.messages` array will contain, at maximum, 500 messages. But what about all the previous +messages? That's what the `cursor` argument is used for 😎. + +Inside `res` is a property called `response_metadata`, which might (or might not) have a `next_cursor` property. When +that `next_cursor` property exists, and is not an empty string, you know there's still more data in the list. If you +want to read more messages in that channel's history, you would call the method again, but use that value as the +`cursor` argument. **NOTE**: It should be rare that your app needs to read the entire history of a channel, avoid that! +With other methods, such as `users.list`, it would be more common to request the entire list, so that's what we're +illustrating below. + +```javascript +// A function that recursively iterates through each page while a next_cursor exists +function getAllUsers() { + let users = []; + function pageLoaded(res) { + users = users.concat(res.users); + if (res.response_metadata && res.response_metadata.next_cursor && res.response_metadata.cursor !== '') { + return web.users.list({ limit: 100, cursor: res.response_metadata.next_cursor }).then(pageLoaded); + } + return users; + } + return web.users.list({ limit: 100 }).then(pageLoaded); +} + +getAllUsers() + .then(console.log) // prints out the list of users + .catch(console.error); +``` + +Cursor-based pagination, if available for a method, is always preferred. In fact, when you call a cursor-paginated +method without a `cursor` or `limit`, the `WebClient` will **automatically paginate** the requests for you until the +end of the list. Then, each page of results are concatenated, and that list takes the place of the last page in the last +response. In other words, if you don't specify any pagination options then you get the whole list in the result as well +as the non-list properties of the last API call. It's always preferred to perform your own pagination by specifying the +`limit` and/or `cursor` since you can optimize to your own application's needs. + +A few methods that returns lists do not support cursor-based pagination, but do support +[other pagination types](https://api.slack.com/docs/pagination#classic_pagination). These methods will not be +automatically paginated for you, so you should give extra care and use appropriate options to only request a page at a +time. If you don't, you risk failing with `Error`s which have a `code` property set to `errorCode.HTTPError`. + --- ### Customizing the logger diff --git a/docs/_posts/2018-05-11-v4.2.1.md b/docs/_posts/2018-05-11-v4.2.1.md new file mode 100644 index 000000000..5485275fb --- /dev/null +++ b/docs/_posts/2018-05-11-v4.2.1.md @@ -0,0 +1,12 @@ +--- +layout: changelog +--- + +- Adds the `notify_on_cancel` field to the `Dialog` type definition (#541) - thanks @DominikPalo +- Adds `AttachmentAction` type definition to express the type of the `action` property of `MessageAttachment`. (#543, #551) - thanks @brianeletype, @DominikPalo +- Adds the `SelectOption` type defintion and related properties to the `Dialog` type definition. (#549) - thanks @DominikPalo +- Fixes the missing `scopes` property in `WebClient` responses. (#554) - thanks @aoberoi +- Fixes an issue in `RTMClient` where websocket errors in the `connecting:authenticated` state would cause the program + to crash. (#555) thanks @aoberoi +- Fixes an issue where `KeepAlive` would monitor the RTM connection while the websocket was not ready after a + reconnection. (#555) thanks @aoberoi diff --git a/docs/_posts/2018-05-11-v4.2.2.md b/docs/_posts/2018-05-11-v4.2.2.md new file mode 100644 index 000000000..7b31361ea --- /dev/null +++ b/docs/_posts/2018-05-11-v4.2.2.md @@ -0,0 +1,5 @@ +--- +layout: changelog +--- + +- Uses the `"files"` key in `package.json` to implement a whitelist of files that are packed for `npm publish`. diff --git a/docs/_posts/2018-06-05-v4.3.0.md b/docs/_posts/2018-06-05-v4.3.0.md new file mode 100644 index 000000000..1ca8d7792 --- /dev/null +++ b/docs/_posts/2018-06-05-v4.3.0.md @@ -0,0 +1,11 @@ +--- +layout: changelog +--- + +- Adds new permissions method named aliases to `WebClient`: `apps.permissions.resources.list` and + `apps.permissions.scopes.list` (#568) - thanks @ggruiz +- Fixes an issue where an `RTMClient` instance throws errors while trying to reconnect after a connection interuption + (#560) - thanks @aoberoi +- Fixes issue where rate-limit handling in `WebClient` was not triggering, and adds tests (#570, #573) - thanks @ggruiz +- Adds missing `IncomingWebhookResult` type to exports (#562) - thanks @mledom +- Changes `options` argument of `RTMClient#start()` to be optional as it was intended (#567) - thanks @christophehurpeau diff --git a/docs/_posts/2018-06-06-v4.3.1.md b/docs/_posts/2018-06-06-v4.3.1.md new file mode 100644 index 000000000..ea36b1e43 --- /dev/null +++ b/docs/_posts/2018-06-06-v4.3.1.md @@ -0,0 +1,6 @@ +--- +layout: changelog +--- + +- Fixes an issue where `RTMClient` would crash after its connection was interrupted because upon reconnection the + connection monitoring code would improperly handle new messages as pongs. (#578) - thanks @aoberoi. diff --git a/docs/_posts/2018-08-10-v4.4.0.md b/docs/_posts/2018-08-10-v4.4.0.md new file mode 100644 index 000000000..538675412 --- /dev/null +++ b/docs/_posts/2018-08-10-v4.4.0.md @@ -0,0 +1,24 @@ +--- +layout: changelog +--- + +## New Features + +- Workspace apps can now call methods on behalf of users for methods which require the `X-Slack-User` header. When + calling one of these methods, specify the user ID in the new `on_behalf_of` option. - thanks @aoberoi (#609) +- The new `rejectRateLimitedCalls` option in the `WebClient` constructor allows you to customize how you'd like to handle + rate limiting. If you set it to `true`, the `WebClient` will not attempt to retry an API call for you, and will instead + return an error with a `code` property set to the value `ErrorCode.RateLimitedError`. - thanks @aoberoi (#599) +- Automatic pagination for cursor-based pagination enabled methods: It's always recommended to perform + pagination using the `cursor` and `limit` options directly, but if you don't pass either when calling a method, the + `WebClient` will automatically iterate through all the pages and returned a merged result. - thanks @aoberoi (#596) +- The `WebClient` will warn when calling deprecated methods (`files.comments.add` and `files.comments.edit`) - thanks @aoberoi (#604) + +## Bug fixes and more + +- Fixes the crash when `RTMClient#disconnect()` was called from the `connecting` state - thanks @aoberoi (#603) +- Fixes an issue where uploading a file without a token fails in `WebClient` with an unrelated error - thanks @aoberoi (#587) +- Resolves an issue where your app requires a newer version of `@types/node` than this package specifies - thanks @aoberoi (#605) +- Fixes the `Dialog.selected_options` type definition - thanks @harveyr (#588) +- Adds information, fixes syntax issues, and corrects typos in the documentation - thanks @chris-peterson, @jd0920 (#584, #600, #601) +- Tests against node v10 in Travis - thanks @aoberoi (#606) diff --git a/docs/_reference/IncomingWebhook.md b/docs/_reference/IncomingWebhook.md index 305f90fe7..68face752 100644 --- a/docs/_reference/IncomingWebhook.md +++ b/docs/_reference/IncomingWebhook.md @@ -8,13 +8,13 @@ A client for Slack's Incoming Webhooks **Kind**: static class of [@slack/client](#module_@slack/client) * [.IncomingWebhook](#module_@slack/client.IncomingWebhook) - * [.send(message)](#module_@slack/client.IncomingWebhook+send) ⇒ Promise.<module:@slack/client/dist/IncomingWebhook.IncomingWebhookResult> + * [.send(message)](#module_@slack/client.IncomingWebhook+send) ⇒ [Promise.<IncomingWebhookResult>](#module_@slack/client.IncomingWebhookResult) * [.send(message, callback)](#module_@slack/client.IncomingWebhook+send) * [.send(message, callback)](#module_@slack/client.IncomingWebhook+send) -### incomingWebhook.send(message) ⇒ Promise.<module:@slack/client/dist/IncomingWebhook.IncomingWebhookResult> +### incomingWebhook.send(message) ⇒ [Promise.<IncomingWebhookResult>](#module_@slack/client.IncomingWebhookResult) Send a notification to a conversation **Kind**: instance method of [IncomingWebhook](#module_@slack/client.IncomingWebhook) diff --git a/package.json b/package.json index f419b1f77..537f6ed1c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slack/client", - "version": "4.2.0", + "version": "4.4.0", "description": "Slack Developer Kit - official clients for the Web API, RTM API, and Incoming Webhooks", "author": "Slack Technologies, Inc.", "license": "MIT", @@ -17,6 +17,9 @@ ], "main": "./dist/index.js", "types": "./dist/index.d.ts", + "files": [ + "dist/**/*" + ], "engines": { "node": ">= 6.9.0", "npm": ">= 3.10.8" @@ -40,19 +43,17 @@ "docs:jsdoc": "ts2jsdoc" }, "dependencies": { - "@types/delay": "^2.0.1", "@types/form-data": "^2.2.1", "@types/got": "^7.1.7", "@types/is-stream": "^1.1.0", "@types/loglevel": "^1.5.3", - "@types/node": "^9.4.7", + "@types/node": ">=6.0.0", "@types/p-cancelable": "^0.3.0", "@types/p-queue": "^2.3.1", "@types/p-retry": "^1.0.1", "@types/retry": "^0.10.2", "@types/url-join": "^0.8.2", - "@types/ws": "^4.0.1", - "delay": "^2.0.0", + "@types/ws": "^5.1.1", "eventemitter3": "^3.0.0", "finity": "^0.5.4", "form-data": "^2.3.1", @@ -64,16 +65,16 @@ "object.values": "^1.0.4", "p-cancelable": "^0.3.0", "p-queue": "^2.3.0", - "p-retry": "^1.0.0", - "retry": "^0.10.1", + "p-retry": "^2.0.0", + "retry": "^0.12.0", "url-join": "^4.0.0", - "ws": "^4.1.0" + "ws": "^5.2.0" }, "devDependencies": { + "@aoberoi/capture-console": "^1.0.0", "@types/chai": "^4.1.2", "@types/mocha": "^2.2.48", "busboy": "^0.2.14", - "capture-stdout": "^1.0.0", "chai": "^4.1.2", "codecov": "^3.0.0", "husky": "^0.14.3", diff --git a/src/IncomingWebhook.ts b/src/IncomingWebhook.ts index d29d7dde7..7dd7576a7 100644 --- a/src/IncomingWebhook.ts +++ b/src/IncomingWebhook.ts @@ -29,7 +29,6 @@ export class IncomingWebhook { /** * Send a notification to a conversation * @param message the message (a simple string, or an object describing the message) - * @param callback */ public send(message: string | IncomingWebhookSendArguments): Promise; public send(message: string | IncomingWebhookSendArguments, callback: IncomingWebhookResultCallback): void; @@ -75,7 +74,6 @@ export class IncomingWebhook { /** * Processes an HTTP response into an IncomingWebhookResult. - * @param response */ private buildResult(response: got.Response): IncomingWebhookResult { return { @@ -147,7 +145,6 @@ function requestErrorWithOriginal(original: Error): IncomingWebhookRequestError return (error as IncomingWebhookRequestError); } - /** * A factory to create IncomingWebhookReadError objects * @param original The original error @@ -161,7 +158,6 @@ function readErrorWithOriginal(original: Error): IncomingWebhookReadError { return (error as IncomingWebhookReadError); } - /** * A factory to create IncomingWebhookHTTPError objects * @param original The original error diff --git a/src/KeepAlive.ts b/src/KeepAlive.ts index d26073198..441cc1807 100644 --- a/src/KeepAlive.ts +++ b/src/KeepAlive.ts @@ -42,6 +42,11 @@ export class KeepAlive extends EventEmitter { */ private pingTimer?: NodeJS.Timer; + /** + * A timer for when to stop listening for an incoming event that acknowledges the ping (counts as a pong) + */ + private pongTimer?: NodeJS.Timer; + /** * The message ID of the latest ping sent, or undefined is there hasn't been one sent. */ @@ -60,12 +65,12 @@ export class KeepAlive extends EventEmitter { /** * Flag that indicates whether this object is still monitoring. */ - public isMonitoring?: Boolean; + public isMonitoring: boolean; /** * Flag that indicates whether recommend_reconnect event has been emitted and stop() has not been called. */ - public recommendReconnect?: Boolean; + public recommendReconnect: boolean; constructor({ clientPingTimeout = 6000, @@ -85,6 +90,9 @@ export class KeepAlive extends EventEmitter { ); } + this.isMonitoring = false; + this.recommendReconnect = false; + // Logging if (logger !== undefined) { this.logger = loggerFromLoggingFunc(KeepAlive.loggerName, logger); @@ -96,9 +104,10 @@ export class KeepAlive extends EventEmitter { /** * Start monitoring the RTMClient. This method should only be called after the client's websocket is already open. - * @param client */ public start(client: RTMClient): void { + this.logger.debug('start monitoring'); + if (!client.connected) { throw errorWithCode( new Error(), @@ -118,7 +127,14 @@ export class KeepAlive extends EventEmitter { * after that. */ public stop(): void { + this.logger.debug('stop monitoring'); + this.clearPreviousPingTimer(); + this.clearPreviousPongTimer(); + if (this.client !== undefined) { + this.client.off('outgoing_message', this.setPingTimer); + this.client.off('slack_event', this.attemptAcknowledgePong); + } this.lastPing = this.client = undefined; this.recommendReconnect = this.isMonitoring = false; } @@ -154,40 +170,40 @@ export class KeepAlive extends EventEmitter { private sendPing(): void { try { if (this.client === undefined) { + if (!this.isMonitoring) { + // if monitoring stopped before the ping timer fires, its safe to return + this.logger.debug('stopped monitoring before ping timer fired'); + return; + } throw errorWithCode(new Error('no client found'), ErrorCode.KeepAliveInconsistentState); } this.logger.debug('ping timer expired, sending ping'); this.client.send('ping') .then((messageId) => { if (this.client === undefined) { + if (!this.isMonitoring) { + // if monitoring stopped before the ping is sent, its safe to return + this.logger.debug('stopped monitoring before outgoing ping message was finished'); + return; + } throw errorWithCode(new Error('no client found'), ErrorCode.KeepAliveInconsistentState); } this.lastPing = messageId; - const attemptAcknowledgePong = function (this: KeepAlive, _type: string, event: any): void { - if (this.client === undefined) { - throw errorWithCode(new Error('no client found'), ErrorCode.KeepAliveInconsistentState); - } - - if (this.lastPing !== undefined && event.reply_to !== undefined && event.reply_to >= this.lastPing) { - // this message is a reply that acks the previous ping, clear the last ping - this.logger.debug('received pong, clearing pong timer'); - delete this.lastPing; - - // signal that this pong is done being handled - clearTimeout(pongTimer); - this.client.off('slack_event', attemptAcknowledgePong); - } - }; - this.logger.debug('setting pong timer'); - const pongTimer = setTimeout(() => { + + this.pongTimer = setTimeout(() => { if (this.client === undefined) { + // if monitoring stopped before the pong timer fires, its safe to return + if (!this.isMonitoring) { + this.logger.debug('stopped monitoring before pong timer fired'); + return; + } throw errorWithCode(new Error('no client found'), ErrorCode.KeepAliveInconsistentState); } // signal that this pong is done being handled - this.client.off('slack_event', attemptAcknowledgePong); + this.client.off('slack_event', this.attemptAcknowledgePong); // no pong received to acknowledge the last ping within the serverPongTimeout this.logger.debug('pong timer expired, recommend reconnect'); @@ -195,7 +211,7 @@ export class KeepAlive extends EventEmitter { this.emit('recommend_reconnect'); }, this.serverPongTimeout); - this.client.on('slack_event', attemptAcknowledgePong, this); + this.client.on('slack_event', this.attemptAcknowledgePong, this); }) .catch((error) => { this.logger.error(`Unhandled error: ${error.message}. Please report to @slack/client package maintainers.`); @@ -204,4 +220,34 @@ export class KeepAlive extends EventEmitter { this.logger.error(`Unhandled error: ${error.message}. Please report to @slack/client package maintainers.`); } } + + /** + * Clears the pong timer if its set, otherwise this is a noop. + */ + private clearPreviousPongTimer(): void { + if (this.pongTimer !== undefined) { + clearTimeout(this.pongTimer); + } + } + + /** + * Determines if a giving incoming event can be treated as an acknowledgement for the outstanding ping, and then + * clears the ping if so. + * @param event any incoming slack event + */ + private attemptAcknowledgePong(_type: string, event: any): void { + if (this.client === undefined) { + throw errorWithCode(new Error('no client found'), ErrorCode.KeepAliveInconsistentState); + } + + if (this.lastPing !== undefined && event.reply_to !== undefined && event.reply_to >= this.lastPing) { + // this message is a reply that acks the previous ping, clear the last ping + this.logger.debug('received pong, clearing pong timer'); + delete this.lastPing; + + // signal that this pong is done being handled + this.clearPreviousPongTimer(); + this.client.off('slack_event', this.attemptAcknowledgePong); + } + } } diff --git a/src/RTMClient.ts b/src/RTMClient.ts index ea2e78e90..afc366b95 100644 --- a/src/RTMClient.ts +++ b/src/RTMClient.ts @@ -145,29 +145,21 @@ export class RTMClient extends EventEmitter { }) .on('websocket open').transitionTo('handshaking') .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('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; @@ -221,12 +213,10 @@ export class RTMClient extends EventEmitter { }) .state('disconnecting') .onEnter(() => { - // invariant: websocket exists and is open at the start of this state + // 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(); - } else { - this.logger.error('Websocket not found when transitioning into disconnecting state. Please report to ' + - '@slack/client package maintainers.'); } }) .on('websocket close').transitionTo('disconnected') @@ -234,7 +224,10 @@ export class RTMClient extends EventEmitter { // 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(() => Promise.resolve(true)) + .do(() => { + this.keepAlive.stop(); + return Promise.resolve(true); + }) .onSuccess().transitionTo('connecting') .onExit(() => this.teardownWebsocket()) .global() @@ -290,7 +283,6 @@ export class RTMClient extends EventEmitter { */ private static loggerName = `${pkg.name}:RTMClient`; - /** * This object's logger instance */ @@ -337,6 +329,11 @@ export class RTMClient extends EventEmitter { 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); @@ -356,9 +353,8 @@ export class RTMClient extends EventEmitter { /** * Begin an RTM session using the provided options. This method must be called before any messages can * be sent or received. - * @param options */ - public start(options: methods.RTMStartArguments | methods.RTMConnectArguments): void { + public start(options?: methods.RTMStartArguments | methods.RTMConnectArguments): void { // TODO: should this return a Promise? // TODO: make a named interface for the type of `options`. it should end in -Options instead of Arguments. @@ -543,7 +539,6 @@ export class RTMClient extends EventEmitter { /** * Set up method for the client's websocket instance. This method will attach event listeners. - * @param url */ private setupWebsocket(url: string): void { // initialize the websocket @@ -581,7 +576,6 @@ export class RTMClient extends EventEmitter { /** * `onmessage` handler for the client's websocket. This will parse the payload and dispatch the relevant events for * each incoming message. - * @param websocketMessage */ private onWebsocketMessage({ data }: { data: string }): void { // v3 legacy @@ -679,7 +673,6 @@ export interface RTMWebsocketError extends CodedError { /** * A factory to create RTMWebsocketError objects. - * @param original */ function websocketErrorWithOriginal(original: Error): RTMWebsocketError { const error = errorWithCode( diff --git a/src/WebClient.spec.js b/src/WebClient.spec.js index fa7750a95..0165e5b98 100644 --- a/src/WebClient.spec.js +++ b/src/WebClient.spec.js @@ -1,18 +1,21 @@ require('mocha'); const fs = require('fs'); const path = require('path'); -const { Agent } = require('http'); +const { Agent } = require('https'); +const { Readable } = require('stream'); const { assert } = require('chai'); const { WebClient } = require('./WebClient'); +const { ErrorCode } = require('./errors'); const { LogLevel } = require('./logger'); const { addAppMetadata } = require('./util'); -const CaptureStdout = require('capture-stdout'); +const rapidRetryPolicy = require('./retry-policies').rapidRetryPolicy; +const { CaptureConsole } = require('@aoberoi/capture-console'); const isPromise = require('p-is-promise'); const nock = require('nock'); const Busboy = require('busboy'); +const sinon = require('sinon'); const token = 'xoxa-faketoken'; -const fastRetriesForTest = { minTimeout: 0, maxTimeout: 1 }; describe('WebClient', function () { @@ -32,13 +35,13 @@ describe('WebClient', function () { describe('has an option to change the log output severity', function () { beforeEach(function () { - this.capture = new CaptureStdout(); + this.capture = new CaptureConsole(); this.capture.startCapture(); }); it('outputs a debug log on initialization', function () { const debuggingClient = new WebClient(token, { logLevel: LogLevel.DEBUG }); const output = this.capture.getCapturedText(); - assert.isNotEmpty(output); // should have 2 log lines, but not asserting since that is an implementation detail + assert.isNotEmpty(output); // should have at least 1 log line, but not asserting since that is an implementation detail }); afterEach(function () { this.capture.stopCapture(); @@ -47,7 +50,7 @@ describe('WebClient', function () { describe('has an option to provide a logging function', function () { beforeEach(function () { - this.capture = new CaptureStdout(); + this.capture = new CaptureConsole(); this.capture.startCapture(); }); it('sends logs to the function and not to stdout', function () { @@ -71,14 +74,18 @@ describe('WebClient', function () { describe('apiCall()', function () { beforeEach(function () { - this.client = new WebClient(token, { retryConfig: fastRetriesForTest }); + this.client = new WebClient(token, { retryConfig: rapidRetryPolicy }); }); describe('when making a successful call', function () { beforeEach(function () { this.scope = nock('https://slack.com') .post(/api/) - .reply(200, { ok: true }); + .reply(200, { ok: true, + response_metadata: { + warnings: ['testWarning1', 'testWarning2'] + } + }); }); it('should return results in a Promise', function () { @@ -90,6 +97,19 @@ describe('WebClient', function () { }); }); + it('should send warnings to logs', function() { + const output = []; + const stub = function (level, message) { + output.push([level, message]); + } + const warnClient = new WebClient(token, { logLevel: LogLevel.WARN, logger: stub }); + return warnClient.apiCall('method') + .then((result) => { + assert.isNotEmpty(output); + assert.lengthOf(output, 2, 'two logs pushed onto output'); + }); + }); + it('should deliver results in a callback', function (done) { this.client.apiCall('method', {}, (error, result) => { assert.isNotOk(error); @@ -100,6 +120,23 @@ describe('WebClient', function () { }); }); + describe('with OAuth scopes in the response headers', function () { + it('should expose a scopes and acceptedScopes properties on the result', function () { + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true }, { + 'X-OAuth-Scopes': 'files:read, chat:write:bot', + 'X-Accepted-OAuth-Scopes': 'files:read' + }); + return this.client.apiCall('method') + .then((result) => { + assert.deepNestedInclude(result, { 'scopes': ['files:read', 'chat:write:bot'] }); + assert.deepNestedInclude(result, { 'acceptedScopes': ['files:read'] }); + scope.done(); + }) + }); + }); + describe('when called with bad options', function () { it('should reject its Promise with TypeError', function (done) { const results = [ @@ -131,8 +168,7 @@ describe('WebClient', function () { }); }); - // TODO: simulate each of the error types - describe('when the call fails', function () { + describe('when an API call fails', function () { beforeEach(function () { this.scope = nock('https://slack.com') .post(/api/) @@ -142,8 +178,9 @@ describe('WebClient', function () { it('should return a Promise which rejects on error', function (done) { const r = this.client.apiCall('method') assert(isPromise(r)); - r.catch(error => { - assert.ok(true); + r.catch((error) => { + assert.instanceOf(error, Error); + this.scope.done(); done(); }); }); @@ -151,11 +188,81 @@ describe('WebClient', function () { it('should deliver error in a callback', function (done) { this.client.apiCall('method', {}, (error) => { assert.instanceOf(error, Error); + this.scope.done(); done(); }); }); }); + it('should fail with WebAPIPlatformError when the API response has an error', function (done) { + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: false, error: 'bad error' }); + this.client.apiCall('method') + .catch((error) => { + assert.instanceOf(error, Error); + assert.equal(error.code, ErrorCode.PlatformError); + assert.nestedPropertyVal(error, 'data.ok', false); + assert.nestedPropertyVal(error, 'data.error', 'bad error'); + scope.done(); + done(); + }); + }); + + it('should fail with WebAPIHTTPError when the API response has an unexpected status', function (done) { + const body = { foo: 'bar' }; + const scope = nock('https://slack.com') + .post(/api/) + .reply(500, body); + const client = new WebClient(token, { retryConfig: { retries: 0 } }); + client.apiCall('method') + .catch((error) => { + assert.instanceOf(error, Error); + assert.equal(error.code, ErrorCode.HTTPError); + assert.instanceOf(error.original, Error); // TODO: deprecate + assert.equal(error.statusCode, 500); + assert.exists(error.headers); + assert.deepEqual(error.body, body); + scope.done(); + done(); + }); + }); + + it('should fail with WebAPIRequestError when the API request fails', function (done) { + // One known request error is when the node encounters an ECONNREFUSED. In order to simulate this, rather than + // using nock, we send the request to a host:port that is not listening. + const client = new WebClient(token, { slackApiUrl: 'https://localhost:8999/api/', retryConfig: { retries: 0 } }); + client.apiCall('method') + .catch((error) => { + assert.instanceOf(error, Error); + assert.equal(error.code, ErrorCode.RequestError); + assert.instanceOf(error.original, Error); + done(); + }); + }); + + // Despite trying, could not figure out a good way to simulate a response that emits an error in a reliable way + it.skip('should fail with WebAPIReadError when an API response fails', function (done) { + class FailingReadable extends Readable { + constructor(options) { super(options); } + _read(size) { + this.emit('error', new Error('test error')); + } + } + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, () => new FailingReadable()); + const client = new WebClient(token, { retryConfig: { retries: 0 } }); + client.apiCall('method') + .catch((error) => { + assert.instanceOf(error, Error); + assert.equal(error.code, ErrorCode.ReadError); + assert.instanceOf(error.original, Error); + scope.done(); + done(); + }); + }); + it('should properly serialize simple API arguments', function () { const scope = nock('https://slack.com') // NOTE: this could create false negatives if the serialization order changes (it shouldn't matter) @@ -204,7 +311,20 @@ describe('WebClient', function () { }); }); - it('should remove undefined or null values from complex API arguments'); + it('should the user on whose behalf the method is called in the request headers', function () { + const userId = 'USERID'; + const scope = nock('https://slack.com', { + reqheaders: { + 'X-Slack-User': userId, + }, + }) + .post(/api/) + .reply(200, { ok: true }); + return this.client.apiCall('method', { on_behalf_of: userId }) + .then(() => { + scope.done(); + }); + }); describe('when API arguments contain binary to upload', function () { beforeEach(function () { @@ -279,6 +399,20 @@ describe('WebClient', function () { assert.isString(file.filename); }); }); + + it('should filter out undefined values', function () { + const imageBuffer = fs.readFileSync(path.resolve('test', 'fixtures', 'train.jpg')); + + return this.client.apiCall('upload', { + // the binary argument is necessary to trigger form data serialization + someBinaryField: imageBuffer, + someUndefinedField: undefined, + }) + .then((parts) => { + // the only field is the one related to the token + assert.lengthOf(parts.fields, 1); + }) + }); }); describe('metadata in the user agent', function () { @@ -321,7 +455,7 @@ describe('WebClient', function () { .post(/api/) .reply(200, { ok: true }); // NOTE: appMetaData is only evalued on client construction, so we cannot use the client already created - const client = new WebClient(token, { retryConfig: fastRetriesForTest }); + const client = new WebClient(token, { retryConfig: rapidRetryPolicy }); return client.apiCall('method') .then(() => { scope.done(); @@ -331,23 +465,25 @@ describe('WebClient', function () { }); describe('apiCall() - without a token', function () { - const client = new WebClient(undefined, { retryConfig: fastRetriesForTest }); + it('should make successful api calls', function () { + const client = new WebClient(undefined, { retryConfig: rapidRetryPolicy }); - const scope = nock('https://slack.com') - // NOTE: this could create false negatives if the serialization order changes (it shouldn't matter) - .post(/api/, 'foo=stringval') - .reply(200, { ok: true }); + const scope = nock('https://slack.com') + // NOTE: this could create false negatives if the serialization order changes (it shouldn't matter) + .post(/api/, 'foo=stringval') + .reply(200, { ok: true }); - const r = client.apiCall('method', { foo: 'stringval' }); - assert(isPromise(r)); - return r.then(result => { - scope.done(); + const r = client.apiCall('method', { foo: 'stringval' }); + assert(isPromise(r)); + return r.then((result) => { + scope.done(); + }); }); }); describe('named method aliases (facets)', function () { beforeEach(function () { - this.client = new WebClient(token, { retryConfig: fastRetriesForTest }); + this.client = new WebClient(token, { retryConfig: rapidRetryPolicy }); }); it('should properly mount methods as functions', function () { // This test doesn't exhaustively check all the method aliases, it just tries a couple. @@ -377,11 +513,23 @@ describe('WebClient', function () { }); describe('has an option to set a custom HTTP agent', function () { - // not confident how to test this. one idea is to use sinon to intercept method calls on the agent. - it.skip('should send a request using the custom agent', function () { - const agent = new Agent(); + it('should send a request using the custom agent', function () { + const agent = new Agent({ keepAlive: true }); + const spy = sinon.spy(agent, 'addRequest'); const client = new WebClient(token, { agent }); - return client.apiCall('method'); + return client.apiCall('method') + .catch(() => { + assert(spy.called); + }) + .then(() => { + agent.addRequest.restore(); + agent.destroy(); + }) + .catch((error) => { + agent.addRequest.restore(); + agent.destroy(); + throw error; + }); }); }); @@ -453,14 +601,13 @@ describe('WebClient', function () { }); describe('has an option to set the retry policy ', function () { - it('retries a request which fails to get a response', function () { const scope = nock('https://slack.com') .post(/api/) - .replyWithError('could be a ECONNREFUESD, ENOTFOUND, ETIMEDOUT, ECONNRESET') + .replyWithError('could be a ECONNREFUSED, ENOTFOUND, ETIMEDOUT, ECONNRESET') .post(/api/) .reply(200, { ok: true }); - const client = new WebClient(token, { retryConfig: fastRetriesForTest }); + const client = new WebClient(token, { retryConfig: rapidRetryPolicy }); return client.apiCall('method') .then((resp) => { assert.propertyVal(resp, 'ok', true); @@ -473,7 +620,7 @@ describe('WebClient', function () { .reply(500) .post(/api/) .reply(200, { ok: true }); - const client = new WebClient(token, { retryConfig: fastRetriesForTest }); + const client = new WebClient(token, { retryConfig: rapidRetryPolicy }); return client.apiCall('method') .then((resp) => { assert.propertyVal(resp, 'ok', true); @@ -483,15 +630,272 @@ describe('WebClient', function () { }); describe('has rate limit handling', function () { - it('should expose retry headers in the response'); - // NOTE: see retry policy note below - it('should allow rate limit triggered retries to be turned off'); - - describe('when a request fails due to rate-limiting', function () { - // NOTE: is this retrying configurable with the retry policy? is it subject to the request concurrency? - it('should automatically retry the request after the specified timeout'); - it('should pause the remaining requests in queue'); - it('should emit a rate_limited event on the client'); + describe('when configured to reject rate-limited calls', function () { + beforeEach(function () { + this.client = new WebClient(token, { rejectRateLimitedCalls: true }); + }); + + it('should reject with a WebAPIRateLimitedError when a request fails due to rate-limiting', function (done) { + const retryAfter = 5; + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, '', { 'retry-after': retryAfter }); + this.client.apiCall('method') + .catch((error) => { + assert.instanceOf(error, Error); + assert.equal(error.code, ErrorCode.RateLimitedError); + assert.equal(error.retryAfter, retryAfter); + scope.done(); + done(); + }); + }); + + it('should emit a rate_limited event on the client', function (done) { + const spy = sinon.spy(); + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, {}, { 'retry-after': 0 }); + const client = new WebClient(token, { rejectRateLimitedCalls: true }); + client.on('rate_limited', spy); + client.apiCall('method') + .catch((err) => { + assert(spy.calledOnceWith(0)) + scope.done(); + done(); + }); + }); + }); + + it('should automatically retry the request after the specified timeout', function () { + const retryAfter = 1; + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, '', { 'retry-after': retryAfter }) + .post(/api/) + .reply(200, { ok: true }); + const client = new WebClient(token, { retryConfig: rapidRetryPolicy }); + const startTime = Date.now(); + return client.apiCall('method') + .then(() => { + const diff = Date.now() - startTime; + assert.isAtLeast(diff, retryAfter * 1000, 'elapsed time is at least a second'); + scope.done(); + }); + }); + + it('should pause the remaining requests in queue', function () { + const startTime = Date.now(); + const retryAfter = 1; + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, '', { 'retry-after': retryAfter }) + .post(/api/) + .reply(200, function (uri, requestBody) { + return JSON.stringify({ ok: true, diff: Date.now() - startTime }); + }) + .post(/api/) + .reply(200, function (uri, requestBody) { + return JSON.stringify({ ok: true, diff: Date.now() - startTime }); + }); + const client = new WebClient(token, { retryConfig: rapidRetryPolicy, maxRequestConcurrency: 1 }); + const firstCall = client.apiCall('method'); + const secondCall = client.apiCall('method'); + return Promise.all([firstCall, secondCall]) + .then(([firstResult, secondResult]) => { + assert.isAtLeast(firstResult.diff, retryAfter * 1000); + assert.isAtLeast(secondResult.diff, retryAfter * 1000); + scope.done(); + }); + }); + + it('should emit a rate_limited event on the client', function (done) { + const spy = sinon.spy(); + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, {}, { 'retry-after': 0 }); + const client = new WebClient(token, { retryConfig: { retries: 0 } }); + client.on('rate_limited', spy); + client.apiCall('method') + .catch((err) => { + assert(spy.calledOnceWith(0)) + scope.done(); + done(); + }); + }); + // TODO: when parsing the retry header fails + }); + + describe('has support for automatic pagination', function () { + beforeEach(function () { + this.client = new WebClient(token); + }); + + describe('when using a method that supports cursor-based pagination', function () { + it('should automatically paginate and return a single merged result when no pagination options are supplied', function () { + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true, channels: ['CONVERSATION_ONE', 'CONVERSATION_TWO'], response_metadata: { next_cursor: 'CURSOR' } }) + .post(/api/, (body) => { + // NOTE: limit value is compared as a string because nock doesn't properly serialize the param into a number + return body.limit && body.limit === '200' && body.cursor && body.cursor === 'CURSOR'; + }) + .reply(200, { ok: true, channels: ['CONVERSATION_THREE'], response_metadata: { some_key: 'some_val' }}); + + return this.client.channels.list() + .then((result) => { + assert.lengthOf(result.channels, 3); + assert.deepEqual(result.channels, ['CONVERSATION_ONE', 'CONVERSATION_TWO', 'CONVERSATION_THREE']) + // the following line makes sure that besides the paginated property, other properties of the result are + // sourced from the last response + assert.propertyVal(result.response_metadata, 'some_key', 'some_val'); + scope.done(); + }); + }); + + it('should allow the automatic page size to be configured', function () { + const pageSize = 400; + const scope = nock('https://slack.com') + .post(/api/, (body) => { + // NOTE: limit value is compared as a string because nock doesn't properly serialize the param into a number + return body.limit && body.limit === ('' + pageSize); + }) + .reply(200, { ok: true, channels: [], response_metadata: {} }) + + const client = new WebClient(token, { pageSize }); + return client.channels.list() + .then((result) => { + scope.done(); + }); + }); + + it('should not automatically paginate when pagination options are supplied', function () { + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true, channels: ['CONVERSATION_ONE'], response_metadata: { next_cursor: 'CURSOR' } }); + + return this.client.channels.list({ limit: 1 }) + .then((result) => { + assert.deepEqual(result.channels, ['CONVERSATION_ONE']) + scope.done(); + }); + }); + + it('should warn when pagination options for timeline or traditional pagination are supplied', function () { + const capture = new CaptureConsole(); + capture.startCapture(); + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true, messages: [] }); + + // this method supports both cursor-based and timeline-based pagination + return this.client.conversations.history({ oldest: 'MESSAGE_TIMESTAMP' }) + .then(() => { + const output = capture.getCapturedText(); + assert.isNotEmpty(output); + scope.done(); + }) + .then(() => { + capture.stopCapture(); + }, (error) => { + capture.stopCapture(); + throw error; + }); + }); + }); + + it('should warn when options indicate mixed pagination types', function () { + const capture = new CaptureConsole(); + capture.startCapture(); + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true, messages: [] }); + + // oldest indicates timeline-based pagination, cursor indicates cursor-based pagination + return this.client.conversations.history({ oldest: 'MESSAGE_TIMESTAMP', cursor: 'CURSOR' }) + .then(() => { + const output = capture.getCapturedText(); + assert.isNotEmpty(output); + scope.done(); + }) + .then(() => { + capture.stopCapture(); + }, (error) => { + capture.stopCapture(); + throw error; + }); + }); + + it('should warn when the options indicate a pagination type that is incompatible with the method', function () { + const capture = new CaptureConsole(); + capture.startCapture(); + const scope = nock('https://slack.com') + .persist() + .post(/api/) + // its important to note that these requests all indicate some kind of pagination, so there should be no + // auto-pagination, and therefore there's no need to specify a list-type data in the response. + .reply(200, { ok: true }); + + const requests = [ + // when the options are cursor and the method is not + this.client.channels.history({ cursor: 'CURSOR' }), + // when the options are timeline and the method is not + this.client.channels.list({ oldest: 'MESSAGE_TIMESTAMP' }), + // when the options are traditional and the method is not + this.client.channels.list({ page: 3, count: 100 }), + ]; + + return Promise.all(requests) + .then(() => { + const output = capture.getCapturedText(); + assert.isAtLeast(output.length, 3); + scope.done(); + }) + .then(() => { + capture.stopCapture(); + }, (error) => { + capture.stopCapture(); + throw error; + }); + }); + + describe('when using a method that supports only non-cursor pagination techniques', function () { + it('should not automatically paginate', function () { + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true, messages: [{}, {}], has_more: false }); + + return this.client.mpim.history({ channel: 'MPIM_ID' }) + .then((result) => { + assert.isTrue(result.ok); + scope.done(); + }); + }); + }); + }); + + describe('warnings', function () { + it('should warn when calling a deprecated method', function () { + const capture = new CaptureConsole(); + capture.startCapture(); + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true }); + + const client = new WebClient(token); + return client.files.comments.add({ file: 'FILE', comment: 'COMMENT' }) + .then(() => { + const output = capture.getCapturedText(); + assert.isNotEmpty(output); + const warning = output[0]; + assert.match(warning, /^\[WARN\]/); + scope.done(); + }) + .then(() => { + capture.stopCapture(); + }, (error) => { + capture.stopCapture(); + throw error; + }); }); }); diff --git a/src/WebClient.ts b/src/WebClient.ts index 66370b65e..d49fb59f1 100644 --- a/src/WebClient.ts +++ b/src/WebClient.ts @@ -1,3 +1,7 @@ +// polyfill for async iterable. see: https://stackoverflow.com/a/43694282/305340 +if (Symbol['asyncIterator'] === undefined) { ((Symbol as any)['asyncIterator']) = Symbol.for('asyncIterator'); } + +import { IncomingMessage, IncomingHttpHeaders } from 'http'; import { basename } from 'path'; import { Readable } from 'stream'; import objectEntries = require('object.entries'); // tslint:disable-line:no-require-imports @@ -6,11 +10,10 @@ import isStream = require('is-stream'); // tslint:disable-line:no-require-import import EventEmitter = require('eventemitter3'); // tslint:disable-line:import-name no-require-imports import PQueue = require('p-queue'); // tslint:disable-line:import-name no-require-imports import pRetry = require('p-retry'); // tslint:disable-line:no-require-imports -import delay = require('delay'); // tslint:disable-line:no-require-imports // NOTE: to reduce depedency size, consider https://www.npmjs.com/package/got-lite import got = require('got'); // tslint:disable-line:no-require-imports import FormData = require('form-data'); // tslint:disable-line:no-require-imports import-name -import { callbackify, getUserAgent, AgentOption, TLSOptions } from './util'; +import { awaitAndReduce, callbackify, getUserAgent, delay, AgentOption, TLSOptions } from './util'; import { CodedError, errorWithCode, ErrorCode } from './errors'; import { LogLevel, Logger, LoggingFunc, getLogger, loggerFromLoggingFunc } from './logger'; import retryPolicies, { RetryOptions } from './retry-policies'; @@ -56,6 +59,16 @@ export class WebClient extends EventEmitter { */ private tlsConfig: TLSOptions; + /** + * Automatic pagination page size (limit) + */ + private pageSize: number; + + /** + * Preference for immediately rejecting API calls which result in a rate-limited response + */ + private rejectRateLimitedCalls: boolean; + /** * The name used to prefix all logging generated from this object */ @@ -82,6 +95,8 @@ export class WebClient extends EventEmitter { retryConfig = retryPolicies.retryForeverExponentialCappedRandom, agent = undefined, tls = undefined, + pageSize = 200, + rejectRateLimitedCalls = false, }: WebClientOptions = {}) { super(); this.token = token; @@ -92,6 +107,8 @@ export class WebClient extends EventEmitter { this.agentConfig = agent; // NOTE: may want to filter the keys to only those acceptable for TLS options this.tlsConfig = tls !== undefined ? tls : {}; + this.pageSize = pageSize; + this.rejectRateLimitedCalls = rejectRateLimitedCalls; // Logging if (logger !== undefined) { @@ -127,71 +144,159 @@ export class WebClient extends EventEmitter { throw new TypeError(`Expected an options argument but instead received a ${typeof options}`); } - const requestBody = this.serializeApiCallOptions(Object.assign({ token: this.token }, options)); - - // The following thunk encapsulates the task so that it can be coordinated for retries - const task = () => { - this.logger.debug('request attempt'); - return got.post(urlJoin(this.slackApiUrl, method), - // @ts-ignore using older definitions for package `got`, can remove when type `@types/got` is updated for v8 - Object.assign({ - form: !canBodyBeFormMultipart(requestBody), - body: requestBody, - retries: 0, - headers: { - 'user-agent': this.userAgent, - }, - agent: this.agentConfig, - }, this.tlsConfig), - ) - .catch((error: got.GotError) => { - // Wrap errors in this packages own error types (abstract the implementation details' types) - if (error.name === 'RequestError') { - throw requestErrorWithOriginal(error); - } else if (error.name === 'ReadError') { - throw readErrorWithOriginal(error); - } else if (error.name === 'HTTPError') { - throw httpErrorWithOriginal(error); - } else { - throw error; - } - }) - .then((response: got.Response) => { - const result = this.buildResult(response); - // log warnings in response metadata - if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) { - result.response_metadata.warnings.forEach(this.logger.warn); - } + // warn for methods whose functionality is deprecated + if (method === 'files.comments.add' || method === 'files.comments.edit') { + this.logger.warn( + `File comments are deprecated in favor of file threads. Replace uses of ${method} in your app ` + + 'to take advantage of improvements. See https://api.slack.com/changelog/2018-05-file-threads-soon-tread ' + + 'to learn more.', + ); + } - // handle rate-limiting - if (response.statusCode !== undefined && response.statusCode === 429) { - const retryAfterMs = result.retryAfter !== undefined ? result.retryAfter : (60 * 1000); - // NOTE: the following event could have more information regarding the api call that is being delayed - this.emit('rate_limited', retryAfterMs / 1000); - this.logger.info(`API Call failed due to rate limiting. Will retry in ${retryAfterMs / 1000} seconds.`); - // wait and return the result from calling `task` again after the specified number of seconds - return delay(retryAfterMs).then(task); - } + // build headers + const headers = { + 'user-agent': this.userAgent, + }; + if (options !== undefined && optionsAreUserPerspectiveEnabled(options)) { + headers['X-Slack-User'] = options.on_behalf_of; + delete options.on_behalf_of; + } - // For any error in the API response, treat them as irrecoverable by throwing an AbortError to end retries. - if (!result.ok) { - const error = errorWithCode( - new Error(`An API error occurred: ${result.error}`), - ErrorCode.PlatformError, - ); - error.data = result; - throw new pRetry.AbortError(error); - } + const methodSupportsCursorPagination = methods.cursorPaginationEnabledMethods.has(method); + const optionsPaginationType = getOptionsPaginationType(options); + + // warn in priority of most general pagination problem to most specific pagination problem + if (optionsPaginationType === PaginationType.Mixed) { + this.logger.warn('Options include mixed pagination techniques. ' + + 'Always prefer cursor-based pagination when available'); + } else if (optionsPaginationType === PaginationType.Cursor && + !methodSupportsCursorPagination) { + this.logger.warn('Options include cursor-based pagination while the method cannot support that technique'); + } else if (optionsPaginationType === PaginationType.Timeline && + !methods.timelinePaginationEnabledMethods.has(method)) { + this.logger.warn('Options include timeline-based pagination while the method cannot support that technique'); + } else if (optionsPaginationType === PaginationType.Traditional && + !methods.traditionalPagingEnabledMethods.has(method)) { + this.logger.warn('Options include traditional paging while the method cannot support that technique'); + } else if (methodSupportsCursorPagination && + optionsPaginationType !== PaginationType.Cursor && optionsPaginationType !== PaginationType.None) { + this.logger.warn('Method supports cursor-based pagination and a different tecnique is used in options. ' + + 'Always prefer cursor-based pagination when available'); + } - return result; - }); - }; + const shouldAutoPaginate = methodSupportsCursorPagination && optionsPaginationType === PaginationType.None; + this.logger.debug(`shouldAutoPaginate: ${shouldAutoPaginate}`); + + /** + * Generates a result object for each of the HTTP requests for this API call. API calls will generally only + * generate more than one result when automatic pagination is occurring. + */ + async function* generateResults(this: WebClient): AsyncIterableIterator { + // when result is undefined, that signals that the first of potentially many calls has not yet been made + let result: WebAPICallResult | undefined = undefined; + // paginationOptions stores pagination options not already stored in the options argument + let paginationOptions: methods.CursorPaginationEnabled = {}; + + if (shouldAutoPaginate) { + // these are the default pagination options + paginationOptions = { limit: this.pageSize }; + } - // The following thunk encapsulates the retried task so that it can be coordinated for request queuing - const taskAfterRetries = () => pRetry(task, this.retryConfig); + while (result === undefined || + (shouldAutoPaginate && + (objectEntries(paginationOptions = paginationOptionsForNextPage(result, this.pageSize)).length > 0) + ) + ) { + const requestBody = this.serializeApiCallOptions(Object.assign( + { token: this.token }, + paginationOptions, + options, + )); + + const task = () => this.requestQueue.add( + async () => { + this.logger.debug('will perform http request'); + try { + const response = await got.post(urlJoin(this.slackApiUrl, method), + // @ts-ignore + Object.assign({ + headers, + form: !canBodyBeFormMultipart(requestBody), + body: requestBody, + retries: 0, + throwHttpErrors: false, + agent: this.agentConfig, + }, this.tlsConfig), + ); + this.logger.debug('http response received'); + + if (response.statusCode === 429) { + const retrySec = parseRetryHeaders(response); + if (retrySec !== undefined) { + this.emit('rate_limited', retrySec); + if (this.rejectRateLimitedCalls) { + throw new pRetry.AbortError(rateLimitedErrorWithDelay(retrySec)); + } + this.logger.info(`API Call failed due to rate limiting. Will retry in ${retrySec} seconds.`); + // pause the request queue and then delay the rejection by the amount of time in the retry header + this.requestQueue.pause(); + // NOTE: if there was a way to introspect the current RetryOperation and know what the next timeout + // would be, then we could subtract that time from the following delay, knowing that it the next + // attempt still wouldn't occur until after the rate-limit header has specified. an even better + // solution would be to subtract the time from only the timeout of this next attempt of the + // RetryOperation. this would result in the staying paused for the entire duration specified in the + // header, yet this operation not having to pay the timeout cost in addition to that. + await delay(retrySec * 1000); + // resume the request queue and throw a non-abort error to signal a retry + this.requestQueue.start(); + // TODO: turn this into a WebAPIPlatformError + throw Error('A rate limit was exceeded.'); + } else { + throw new pRetry.AbortError(new Error('Retry header did not contain a valid timeout.')); + } + } + + // Slack's Web API doesn't use meaningful status codes besides 429 and 200 + if (response.statusCode !== 200) { + throw httpErrorFromResponse(response); + } + + result = this.buildResult(response); + + // log warnings in response metadata + if (result.response_metadata !== undefined && result.response_metadata.warnings !== undefined) { + result.response_metadata.warnings.forEach(this.logger.warn); + } + + if (!result.ok) { + const error = errorWithCode( + new Error(`An API error occurred: ${result.error}`), + ErrorCode.PlatformError, + ); + error.data = result; + throw new pRetry.AbortError(error); + } + + return result; + } catch (error) { + this.logger.debug('http request failed'); + if (error.name === 'RequestError') { + throw requestErrorWithOriginal(error); + } else if (error.name === 'ReadError') { + throw readErrorWithOriginal(error); + } + throw error; + } + }, + ); - // The final return value is the resolution of the task after being retried and queued - return this.requestQueue.add(taskAfterRetries); + result = await pRetry(task, this.retryConfig); + yield result; + } + } + + // return a promise that resolves when a reduction of responses finishes + return awaitAndReduce(generateResults.call(this), createResultMerger(method) , {} as WebAPICallResult); }; // Adapt the interface for callback-based execution or Promise-based execution @@ -216,6 +321,14 @@ export class WebClient extends EventEmitter { permissions: { info: (this.apiCall.bind(this, 'apps.permissions.info')) as Method, request: (this.apiCall.bind(this, 'apps.permissions.request')) as Method, + resources: { + list: (this.apiCall.bind(this, 'apps.permissions.resources.list')) as + Method, + }, + scopes: { + list: (this.apiCall.bind(this, 'apps.permissions.scopes.list')) as + Method, + }, }, }; @@ -554,7 +667,7 @@ export class WebClient extends EventEmitter { return defaultFilename; })(); form.append(key, value, options); - } else { + } else if (key !== undefined && value !== undefined) { form.append(key, value); } return form; @@ -573,13 +686,13 @@ export class WebClient extends EventEmitter { /** * Processes an HTTP response into a WebAPICallResult by performing JSON parsing on the body and merging relevent * HTTP headers into the object. - * @param response + * @param response - an http response */ private buildResult(response: got.Response): WebAPICallResult { const data = JSON.parse(response.body); // add scopes metadata from headers - if (response.headers['x-oauth'] !== undefined) { + if (response.headers['x-oauth-scopes'] !== undefined) { data.scopes = (response.headers['x-oauth-scopes'] as string).trim().split(/\s*,\s*/); } if (response.headers['x-accepted-oauth-scopes'] !== undefined) { @@ -587,8 +700,9 @@ export class WebClient extends EventEmitter { } // add retry metadata from headers - if (response.headers['retry-after'] !== undefined) { - data.retryAfter = parseInt((response.headers['retry-after'] as string), 10) * 1000; + const retrySec = parseRetryHeaders(response); + if (retrySec !== undefined) { + data.retryAfter = retrySec; } return data; @@ -609,6 +723,8 @@ export interface WebClientOptions { retryConfig?: RetryOptions; agent?: AgentOption; tls?: TLSOptions; + pageSize?: number; + rejectRateLimitedCalls?: boolean; } // NOTE: could potentially add GotOptions to this interface (using &, or maybe as an embedded key) @@ -621,14 +737,18 @@ export interface WebAPICallResult { scopes?: string[]; acceptedScopes?: string[]; retryAfter?: number; - response_metadata?: { warnings?: string[] }; + response_metadata?: { + warnings?: string[]; + next_cursor?: string; // is this too specific to be encoded into this type? + }; } export interface WebAPIResultCallback { (error: WebAPICallError, result: WebAPICallResult): void; } -export type WebAPICallError = WebAPIPlatformError | WebAPIRequestError | WebAPIReadError | WebAPIHTTPError; +export type WebAPICallError = + WebAPIPlatformError | WebAPIRequestError | WebAPIReadError | WebAPIHTTPError | WebAPIRateLimitedError; export interface WebAPIPlatformError extends CodedError { code: ErrorCode.PlatformError; @@ -649,7 +769,16 @@ export interface WebAPIReadError extends CodedError { export interface WebAPIHTTPError extends CodedError { code: ErrorCode.HTTPError; - original: Error; + original: Error; // TODO: deprecate + statusCode: number; + statusMessage: string; + headers: IncomingHttpHeaders; + body?: any; +} + +export interface WebAPIRateLimitedError extends CodedError { + code: ErrorCode.RateLimitedError; + retryAfter: number; } /* @@ -674,10 +803,16 @@ function canBodyBeFormMultipart(body: FormCanBeURLEncoded | BodyCanBeFormMultipa return isStream(body); } +/** + * Determines whether WebAPICallOptions conform to UserPerspectiveEnabled + */ +function optionsAreUserPerspectiveEnabled(options: WebAPICallOptions): options is methods.UserPerspectiveEnabled { + return (options as any).on_behalf_of !== undefined; +} /** * A factory to create WebAPIRequestError objects - * @param original + * @param original - original error */ function requestErrorWithOriginal(original: Error): WebAPIRequestError { const error = errorWithCode( @@ -691,7 +826,7 @@ function requestErrorWithOriginal(original: Error): WebAPIRequestError { /** * A factory to create WebAPIReadError objects - * @param original + * @param original - original error */ function readErrorWithOriginal(original: Error): WebAPIReadError { const error = errorWithCode( @@ -705,14 +840,135 @@ function readErrorWithOriginal(original: Error): WebAPIReadError { /** * A factory to create WebAPIHTTPError objects - * @param original + * @param original - original error */ -function httpErrorWithOriginal(original: Error): WebAPIHTTPError { +function httpErrorFromResponse(response: got.Response): WebAPIHTTPError { const error = errorWithCode( // any cast is used because the got definition file doesn't export the got.HTTPError type - new Error(`An HTTP protocol error occurred: statusCode = ${(original as any).statusCode}`), + new Error(`An HTTP protocol error occurred: statusCode = ${response.statusCode}`), ErrorCode.HTTPError, ) as Partial; - error.original = original; + error.original = new Error('The WebAPIHTTPError.original property is deprecated. See other properties for details.'); + error.statusCode = response.statusCode; + error.statusMessage = response.statusMessage; + error.headers = response.headers; + try { + error.body = JSON.parse(response.body); + } catch (error) { + error.body = response.body; + } return (error as WebAPIHTTPError); } + +/** + * A factory to create WebAPIRateLimitedError objects + * @param retrySec - Number of seconds that the request can be retried in + */ +function rateLimitedErrorWithDelay(retrySec: number): WebAPIRateLimitedError { + const error = errorWithCode( + new Error(`A rate-limit has been reached, you may retry this request in ${retrySec} seconds`), + ErrorCode.RateLimitedError, + ) as Partial; + error.retryAfter = retrySec; + return (error as WebAPIRateLimitedError); +} + +enum PaginationType { + Cursor = 'Cursor', + Timeline = 'Timeline', + Traditional = 'Traditional', + Mixed = 'Mixed', + None = 'None', +} + +/** + * Determines which pagination type, if any, the supplied options (a.k.a. method arguments) are using. This method is + * also able to determine if the options have mixed different pagination types. + */ +function getOptionsPaginationType(options?: WebAPICallOptions): PaginationType { + if (options === undefined) { + return PaginationType.None; + } + + let optionsType = PaginationType.None; + for (const option of Object.keys(options)) { + if (optionsType === PaginationType.None) { + if (methods.cursorPaginationOptionKeys.has(option)) { + optionsType = PaginationType.Cursor; + } else if (methods.timelinePaginationOptionKeys.has(option)) { + optionsType = PaginationType.Timeline; + } else if (methods.traditionalPagingOptionKeys.has(option)) { + optionsType = PaginationType.Traditional; + } + } else if (optionsType === PaginationType.Cursor) { + if (methods.timelinePaginationOptionKeys.has(option) || methods.traditionalPagingOptionKeys.has(option)) { + return PaginationType.Mixed; + } + } else if (optionsType === PaginationType.Timeline) { + if (methods.cursorPaginationOptionKeys.has(option) || methods.traditionalPagingOptionKeys.has(option)) { + return PaginationType.Mixed; + } + } else if (optionsType === PaginationType.Traditional) { + if (methods.cursorPaginationOptionKeys.has(option) || methods.timelinePaginationOptionKeys.has(option)) { + return PaginationType.Mixed; + } + } + } + return optionsType; +} + +/** + * Creates a function that can reduce a result into an accumulated result. This is used for reducing many results from + * automatically paginated API calls into a single result. It depends on metadata in the 'method' import. + * @param method - the API method for which a result merging function is needed + */ +function createResultMerger(method: string): + (accumulator: WebAPICallResult, result: WebAPICallResult) => WebAPICallResult { + if (methods.cursorPaginationEnabledMethods.has(method)) { + const paginatedResponseProperty = methods.cursorPaginationEnabledMethods.get(method) as string; + return (accumulator: WebAPICallResult, result: WebAPICallResult): WebAPICallResult => { + for (const resultProperty of Object.keys(result)) { + if (resultProperty === paginatedResponseProperty) { + if (accumulator[resultProperty] === undefined) { + accumulator[resultProperty] = []; + } + accumulator[resultProperty] = accumulator[resultProperty].concat(result[resultProperty]); + } else { + accumulator[resultProperty] = result[resultProperty]; + } + } + return accumulator; + }; + } + // For all methods who don't use cursor-pagination, return the identity reduction function + return (_, result) => result; +} + +/** + * Determines an appropriate set of cursor pagination options for the next request to a paginated API method. + * @param previousResult - the result of the last request, where the next cursor might be found. + * @param pageSize - the maximum number of additional items to fetch in the next request. + */ +function paginationOptionsForNextPage( + previousResult: WebAPICallResult, pageSize: number, +): methods.CursorPaginationEnabled { + const paginationOptions: methods.CursorPaginationEnabled = {}; + if (previousResult.response_metadata !== undefined && + previousResult.response_metadata.next_cursor !== undefined && + previousResult.response_metadata.next_cursor !== '') { + paginationOptions.limit = pageSize; + paginationOptions.cursor = previousResult.response_metadata.next_cursor as string; + } + return paginationOptions; +} + +/** + * Extract the amount of time (in seconds) the platform has recommended this client wait before sending another request + * from a rate-limited HTTP response (statusCode = 429). + */ +function parseRetryHeaders(response: IncomingMessage): number | undefined { + if (response.headers['retry-after'] !== undefined) { + return parseInt((response.headers['retry-after'] as string), 10); + } + return undefined; +} diff --git a/src/errors.ts b/src/errors.ts index cbb127961..d8970ef61 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -19,6 +19,7 @@ export enum ErrorCode { ReadError = 'slackclient_read_error', // Corresponds to WebAPIReadError HTTPError = 'slackclient_http_error', // Corresponds to WebAPIHTTPError PlatformError = 'slackclient_platform_error', // Corresponds to WebAPIPlatformError + RateLimitedError = 'slackclient_rate_limited_error', // Corresponds to WebAPIRateLimitedError // RTMClient RTMSendWhileDisconnectedError = 'slackclient_rtmclient_send_while_disconnected_error', @@ -40,9 +41,6 @@ export enum ErrorCode { /** * Factory for producing a {@link CodedError} from a generic error - * - * @param error - * @param code */ export function errorWithCode(error: Error, code: ErrorCode): CodedError { const codedError = error as Partial; diff --git a/src/index.ts b/src/index.ts index 7cfc5f582..b4c54d680 100644 --- a/src/index.ts +++ b/src/index.ts @@ -48,5 +48,6 @@ export { IncomingWebhook, IncomingWebhookSendArguments, IncomingWebhookDefaultArguments, + IncomingWebhookResult, IncomingWebhookResultCallback, } from './IncomingWebhook'; diff --git a/src/logger.ts b/src/logger.ts index 5508c9c50..9857ef5bd 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,6 +1,8 @@ import * as log from 'loglevel'; import { noop } from './util'; +let instanceCount = 0; + /** * Severity levels for log entries */ @@ -78,8 +80,12 @@ log.methodFactory = function ( /** * INTERNAL interface for getting or creating a named Logger */ -// TODO: implement logger name prefixing (example plugins available on the loglevel package's site) -export const getLogger = log.getLogger as (name: string) => Logger; +export function getLogger(name: string): Logger { + // TODO: implement logger name prefixing (example plugins available on the loglevel package's site) + const instanceNumber = instanceCount; + instanceCount += 1; + return log.getLogger(name + instanceNumber); +} /** * Decides whether `level` is more severe than the `threshold` for logging. When this returns true, logs should be @@ -106,11 +112,11 @@ function isMoreSevere(level: LogLevel, threshold: number): boolean { /** * INTERNAL function for transforming an external LoggerFunc type into the internal Logger interface - * @param name - * @param loggingFunc */ export function loggerFromLoggingFunc(name: string, loggingFunc: LoggingFunc): Logger { - const logger = log.getLogger(name); + const instanceNumber = instanceCount; + instanceCount += 1; + const logger = log.getLogger(name + instanceNumber); logger.methodFactory = function (methodName: LogLevel, logLevel, loggerName: string): (...msg: any[]) => void { if (isMoreSevere(methodName, logLevel)) { return function (...msg: any[]): void { diff --git a/src/methods.ts b/src/methods.ts index 62a4a39ec..2d02fca67 100644 --- a/src/methods.ts +++ b/src/methods.ts @@ -8,7 +8,7 @@ import { WebAPICallOptions, WebAPIResultCallback, WebAPICallResult } from './Web * Generic method definition */ export default interface Method { - // TODO: can we create a relationship between MethodArguments and a MethodResult type? + // TODO: can we create a relationship between MethodArguments and a MethodResult type? hint: conditional types (options?: MethodArguments & AuxiliaryArguments): Promise; (options: MethodArguments & AuxiliaryArguments, callback: WebAPIResultCallback): void; } @@ -25,20 +25,53 @@ export interface TokenOverridable { token?: string; } +export interface LocaleAware { + include_locale?: boolean; +} + +export interface Searchable { + query: string; + highlight?: boolean; + sort: 'score' | 'timestamp'; + sort_dir: 'asc' | 'desc'; +} + +// For workspace apps, this argument allows calling a method on behalf of a user +export interface UserPerspectiveEnabled { + on_behalf_of?: string; +} + +// Pagination protocols +// -------------------- +// In order to support automatic pagination in the WebClient, the following pagination types are not only defined as an +// interface to abstract the related arguments, but also all API methods which support the pagination type are added +// to a respective Set, so that the WebClient can reflect on which API methods it may apply automatic pagination. +// As maintainers, we must be careful to add each of the API methods into these sets, so that is handled local (in line +// numbers close) to the application of each interface. + +// TODO: express the interfaces as keyof the sets? + export interface CursorPaginationEnabled { limit?: number; // natural integer, max of 1000 cursor?: string; // find this in a response's `response_metadata.next_cursor` } +export const cursorPaginationOptionKeys = new Set(['limit', 'cursor']); +export const cursorPaginationEnabledMethods: Map = new Map(); // method : paginatedResponseProperty export interface TimelinePaginationEnabled { oldest?: string; latest?: string; inclusive?: boolean; } +export const timelinePaginationOptionKeys = new Set(['oldest', 'latest', 'inclusive']); +export const timelinePaginationEnabledMethods = new Set(); -export interface LocaleAware { - include_locale?: boolean; +export interface TraditionalPagingEnabled { + page?: number; // default: 1 + count?: number; // default: 100 } +export const traditionalPagingOptionKeys = new Set(['page', 'count']); +export const traditionalPagingEnabledMethods = new Set(); /* * Reusable shapes for argument values @@ -59,10 +92,14 @@ export interface Dialog { hint?: string; subtype?: 'email' | 'number' | 'tel' | 'url'; // type `select`: - options?: { - label: string; // shown to user - value: string; // sent to app + data_source?: 'users' | 'channels' | 'conversations' | 'external'; + selected_options?: SelectOption[]; + options?: SelectOption[]; + option_groups?: { + label: string; + options: SelectOption[]; }[]; + min_query_length?: number; }[]; submit_label?: string; notify_on_cancel?: boolean; @@ -97,18 +134,19 @@ export interface AttachmentAction { id?: string; confirm?: Confirmation; data_source?: string; - min_query_length: number; - name: string; + min_query_length?: number; + name?: string; options?: OptionField[]; option_groups?: { text: string options: OptionField[]; }[]; - selected_options: OptionField[]; + selected_options?: OptionField[]; style?: string; text: string; type: string; value?: string; + url?: string; } export interface OptionField { @@ -128,6 +166,11 @@ export interface LinkUnfurls { [linkUrl: string]: MessageAttachment; } +export interface SelectOption { + label: string; // shown to user + value: string; // sent to app +} + /* * MethodArguments types (no formal relationship other than the generic constraint in Method<>) */ @@ -145,6 +188,9 @@ export type AppsPermissionsRequestArguments = TokenOverridable & { scopes: string; // comma-separated list of scopes trigger_id: string; }; +export type AppsPermissionsResourcesListArguments = TokenOverridable & CursorPaginationEnabled; +cursorPaginationEnabledMethods.set('apps.permissions.resources.list', 'resources'); +export type AppsPermissionsScopesListArguments = TokenOverridable & {}; /* * `auth.*` @@ -176,6 +222,7 @@ export type ChannelsHistoryArguments = TokenOverridable & TimelinePaginationEnab count?: number; unreads?: boolean; }; +timelinePaginationEnabledMethods.add('channels.history'); export type ChannelsInfoArguments = TokenOverridable & LocaleAware & { channel: string; }; @@ -198,6 +245,7 @@ export type ChannelsListArguments = TokenOverridable & CursorPaginationEnabled & exclude_archived: boolean; exclude_members: boolean; }; +cursorPaginationEnabledMethods.set('channels.list', 'channels'); export type ChannelsMarkArguments = TokenOverridable & { channel: string; ts: string; @@ -298,6 +346,8 @@ export type ConversationsCreateArguments = TokenOverridable & { export type ConversationsHistoryArguments = TokenOverridable & CursorPaginationEnabled & TimelinePaginationEnabled & { channel: string; }; +cursorPaginationEnabledMethods.set('conversations.history', 'messages'); +timelinePaginationEnabledMethods.add('conversations.history'); export type ConversationsInfoArguments = TokenOverridable & LocaleAware & { channel: string; }; @@ -319,9 +369,11 @@ export type ConversationsListArguments = TokenOverridable & CursorPaginationEnab exclude_archived?: boolean; types?: string; // comma-separated list of conversation types }; +cursorPaginationEnabledMethods.set('conversations.list', 'channels'); export type ConversationsMembersArguments = TokenOverridable & CursorPaginationEnabled & { channel: string; }; +cursorPaginationEnabledMethods.set('conversations.members', 'members'); export type ConversationsOpenArguments = TokenOverridable & { channel?: string; users?: string; // comma-separated list of users @@ -335,6 +387,8 @@ export type ConversationsRepliesArguments = TokenOverridable & CursorPaginationE channel: string; ts: string; }; +cursorPaginationEnabledMethods.set('conversations.replies', 'messages'); +timelinePaginationEnabledMethods.add('conversations.replies'); export type ConversationsSetPurposeArguments = TokenOverridable & { channel: string; purpose: string; @@ -358,12 +412,12 @@ export type DialogOpenArguments = TokenOverridable & { /* * `dnd.*` */ -export type DndEndDndArguments = TokenOverridable; -export type DndEndSnoozeArguments = TokenOverridable; +export type DndEndDndArguments = TokenOverridable & UserPerspectiveEnabled; +export type DndEndSnoozeArguments = TokenOverridable & UserPerspectiveEnabled; export type DndInfoArguments = TokenOverridable & { user: string; }; -export type DndSetSnoozeArguments = TokenOverridable & { +export type DndSetSnoozeArguments = TokenOverridable & UserPerspectiveEnabled & { num_minutes: number; }; export type DndTeamInfoArguments = TokenOverridable & { @@ -381,20 +435,20 @@ export type EmojiListArguments = TokenOverridable; export type FilesDeleteArguments = TokenOverridable & { file: string; // file id }; -export type FilesInfoArguments = TokenOverridable & { +export type FilesInfoArguments = TokenOverridable & CursorPaginationEnabled & { file: string; // file id count?: number; page?: number; }; -export type FilesListArguments = TokenOverridable & { +cursorPaginationEnabledMethods.set('files.info', 'comments'); +export type FilesListArguments = TokenOverridable & TraditionalPagingEnabled & { channel?: string; user?: string; - count?: number; - page?: number; ts_from?: string; ts_to?: string; types?: string; // comma-separated list of file types }; +traditionalPagingEnabledMethods.add('files.list'); export type FilesRevokePublicURLArguments = TokenOverridable & { file: string; // file id }; @@ -437,10 +491,12 @@ export type GroupsCreateArguments = TokenOverridable & { export type GroupsCreateChildArguments = TokenOverridable & { channel: string; }; -export type GroupsHistoryArguments = TokenOverridable & CursorPaginationEnabled & TimelinePaginationEnabled & { +export type GroupsHistoryArguments = TokenOverridable & TimelinePaginationEnabled & { channel: string; unreads?: boolean; + count?: number; }; +timelinePaginationEnabledMethods.add('groups.history'); export type GroupsInfoArguments = TokenOverridable & LocaleAware & { channel: string; }; @@ -455,10 +511,11 @@ export type GroupsKickArguments = TokenOverridable & { export type GroupsLeaveArguments = TokenOverridable & { channel: string; }; -export type GroupsListArguments = TokenOverridable & { +export type GroupsListArguments = TokenOverridable & CursorPaginationEnabled & { exclude_archived?: boolean; exclude_members?: boolean; }; +cursorPaginationEnabledMethods.set('groups.list', 'groups'); export type GroupsMarkArguments = TokenOverridable & { channel: string; ts: string; @@ -498,7 +555,9 @@ export type IMHistoryArguments = TokenOverridable & TimelinePaginationEnabled & count?: number; unreads?: boolean; }; +timelinePaginationEnabledMethods.add('im.history'); export type IMListArguments = TokenOverridable & CursorPaginationEnabled; +cursorPaginationEnabledMethods.set('im.list', 'ims'); export type IMMarkArguments = TokenOverridable & { channel: string; ts: string; @@ -531,7 +590,9 @@ export type MPIMHistoryArguments = TokenOverridable & TimelinePaginationEnabled count?: number; unreads?: boolean; }; -export type MPIMListArguments = TokenOverridable; +timelinePaginationEnabledMethods.add('mpim.history'); +export type MPIMListArguments = TokenOverridable & CursorPaginationEnabled; +cursorPaginationEnabledMethods.set('mpim.list', 'groups'); export type MPIMMarkArguments = TokenOverridable & { channel: string; ts: string; @@ -601,12 +662,12 @@ export type ReactionsGetArguments = TokenOverridable & { file?: string; // file id file_comment?: string; }; -export type ReactionsListArguments = TokenOverridable & { +export type ReactionsListArguments = TokenOverridable & TraditionalPagingEnabled & CursorPaginationEnabled & { user?: string; - count?: number; - page?: number; full?: boolean; }; +cursorPaginationEnabledMethods.set('reactions.list', 'items'); +traditionalPagingEnabledMethods.add('reactions.list'); export type ReactionsRemoveArguments = TokenOverridable & { name: string; // must supply one of: @@ -619,21 +680,21 @@ export type ReactionsRemoveArguments = TokenOverridable & { /* * `reminders.*` */ -export type RemindersAddArguments = TokenOverridable & { +export type RemindersAddArguments = TokenOverridable & UserPerspectiveEnabled & { text: string; time: string | number; user?: string; }; -export type RemindersCompleteArguments = TokenOverridable & { +export type RemindersCompleteArguments = TokenOverridable & UserPerspectiveEnabled & { reminder: string; }; -export type RemindersDeleteArguments = TokenOverridable & { +export type RemindersDeleteArguments = TokenOverridable & UserPerspectiveEnabled & { reminder: string; }; -export type RemindersInfoArguments = TokenOverridable & { +export type RemindersInfoArguments = TokenOverridable & UserPerspectiveEnabled & { reminder: string; }; -export type RemindersListArguments = TokenOverridable; +export type RemindersListArguments = TokenOverridable & UserPerspectiveEnabled; /* * `rtm.*` @@ -654,16 +715,12 @@ export type RTMStartArguments = TokenOverridable & LocaleAware & { /* * `search.*` */ -export type SearchAllArguments = TokenOverridable & { - query: string; - count?: number; - page?: number; - highlight?: boolean; - sort: 'score' | 'timestamp'; - sort_dir: 'asc' | 'desc'; -}; -export type SearchFilesArguments = SearchAllArguments; -export type SearchMessagesArguments = SearchAllArguments; +export type SearchAllArguments = TokenOverridable & TraditionalPagingEnabled & Searchable; +traditionalPagingEnabledMethods.add('search.all'); +export type SearchFilesArguments = TokenOverridable & TraditionalPagingEnabled & Searchable; +traditionalPagingEnabledMethods.add('search.files'); +export type SearchMessagesArguments = TokenOverridable & TraditionalPagingEnabled & Searchable; +traditionalPagingEnabledMethods.add('search.messages'); /* * `stars.*` @@ -675,10 +732,9 @@ export type StarsAddArguments = TokenOverridable & { file?: string; // file id file_comment?: string; }; -export type StarsListArguments = TokenOverridable & { - count?: number; - page?: number; -}; +export type StarsListArguments = TokenOverridable & TraditionalPagingEnabled & CursorPaginationEnabled; +cursorPaginationEnabledMethods.set('stars.list', 'items'); +traditionalPagingEnabledMethods.add('stars.list'); export type StarsRemoveArguments = TokenOverridable & { // must supply one of: channel?: string; // paired with `timestamp` @@ -760,17 +816,19 @@ export type UsersConversationsArguments = TokenOverridable & CursorPaginationEna types?: string; // comma-separated list of conversation types user?: string; }; +cursorPaginationEnabledMethods.set('users.conversations', 'channels'); export type UsersDeletePhotoArguments = TokenOverridable; export type UsersGetPresenceArguments = TokenOverridable & { user: string; }; -export type UsersIdentityArguments = TokenOverridable; +export type UsersIdentityArguments = TokenOverridable & UserPerspectiveEnabled; export type UsersInfoArguments = TokenOverridable & LocaleAware & { user: string; }; export type UsersListArguments = TokenOverridable & CursorPaginationEnabled & LocaleAware & { presence?: boolean; // deprecated, defaults to false }; +cursorPaginationEnabledMethods.set('users.list', 'members'); export type UsersLookupByEmailArguments = TokenOverridable & { email: string; }; @@ -788,7 +846,7 @@ export type UsersProfileGetArguments = TokenOverridable & { include_labels?: boolean; user?: string; }; -export type UsersProfileSetArguments = TokenOverridable & { +export type UsersProfileSetArguments = TokenOverridable & UserPerspectiveEnabled &{ profile?: string; // url-encoded json user?: string; name?: string; // usable if `profile` is not passed diff --git a/src/retry-policies.ts b/src/retry-policies.ts index ac4738ac7..cd6899583 100644 --- a/src/retry-policies.ts +++ b/src/retry-policies.ts @@ -15,7 +15,6 @@ export const retryForeverExponential: RetryOptions = { forever: true, }; - /** * Same as {@link retryForeverExponential}, but capped at 30 minutes. * TODO: should this name really have "forever" in it? if not, remove from all the derived names below @@ -24,7 +23,6 @@ export const retryForeverExponentialCapped: RetryOptions = Object.assign({}, ret maxTimeout: 30 * 60 * 1000, }); - /** * Same as {@link retryForeverExponentialCapped}, but with randomization to * prevent stampeding herds. @@ -33,7 +31,6 @@ export const retryForeverExponentialCappedRandom: RetryOptions = Object.assign({ randomize: true, }); - /** * Short & sweet, five retries in five minutes and then bail. */ @@ -42,7 +39,6 @@ export const fiveRetriesInFiveMinutes: RetryOptions = { factor: 3.86, }; - /** * This policy is just to keep the tests running fast. */ diff --git a/src/util.ts b/src/util.ts index 878ffcf8e..dd75187eb 100644 --- a/src/util.ts +++ b/src/util.ts @@ -11,7 +11,6 @@ export function noop(): void { } // tslint:disable-line:no-empty /** * Replaces occurences of '/' with ':' in a string, since '/' is meaningful inside User-Agent strings as a separator. - * @param s */ function replaceSlashes(s: string): string { return s.replace('/', ':'); @@ -25,7 +24,6 @@ const appMetadata: { [key: string]: string } = {}; /** * Appends the app metadata into the User-Agent value - * @param appMetadata * @param appMetadata.name name of tool to be counted in instrumentation * @param appMetadata.version version of tool to be counted in instrumentation */ @@ -42,6 +40,57 @@ export function getUserAgent(): string { return ((appIdentifier.length > 0) ? `${appIdentifier} ` : '') + baseUserAgent; } +/** + * Build a Promise that will resolve after the specified number of milliseconds. + * @param ms milliseconds to wait + * @param value value for eventual resolution + */ +export function delay(ms: number, value?: T): Promise { + return new Promise((resolve) => { + setTimeout(() => resolve(value), ms); + }); +} + +/** + * Reduce an asynchronous iterable into a single value. + * @param iterable the async iterable to be reduced + * @param callbackfn a function that implements one step of the reduction + * @param initialValue the initial value for the accumulator + */ +export async function awaitAndReduce(iterable: AsyncIterable, + callbackfn: (previousValue: U, currentValue: T) => U, + initialValue: U): Promise { + // TODO: make initialValue optional (overloads or conditional types?) + let accumulator = initialValue; + for await (const value of iterable) { + accumulator = callbackfn(accumulator, value); + } + return accumulator; +} + +/** + * Instead of depending on the util.callbackify type in the `@types/node` package, we're copying the type defintion + * of that function into an interface here. This needs to be manually updated if the type definition in that package + * changes. + */ +// tslint:disable:max-line-length +interface Callbackify { + (fn: () => Promise): (callback: (err: NodeJS.ErrnoException) => void) => void; + (fn: () => Promise): (callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; + (fn: (arg1: T1) => Promise): (arg1: T1, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; + (fn: (arg1: T1, arg2: T2) => Promise): (arg1: T1, arg2: T2, callback: (err: NodeJS.ErrnoException) => void) => void; + (fn: (arg1: T1, arg2: T2) => Promise): (arg1: T1, arg2: T2, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3) => Promise): (arg1: T1, arg2: T2, arg3: T3, callback: (err: NodeJS.ErrnoException) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3) => Promise): (arg1: T1, arg2: T2, arg3: T3, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, callback: (err: NodeJS.ErrnoException) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4) => Promise): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Promise): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, callback: (err: NodeJS.ErrnoException) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5) => Promise): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6) => Promise): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6, callback: (err: NodeJS.ErrnoException) => void) => void; + (fn: (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6) => Promise): (arg1: T1, arg2: T2, arg3: T3, arg4: T4, arg5: T5, arg6: T6, callback: (err: NodeJS.ErrnoException, result: TResult) => void) => void; +} +// tslint:enable:max-line-length + /** * The following is a polyfill of Node >= 8.2.0's util.callbackify method. The source is copied (with some * modification) from: @@ -130,7 +179,7 @@ export const callbackify = util.callbackify !== undefined ? util.callbackify : ( // tslint:enable return callbackify; -}() as typeof util.callbackify); +}() as Callbackify); export type AgentOption = Agent | { http?: Agent, diff --git a/support/jsdoc/@slack-client-dist-IncomingWebhook.js b/support/jsdoc/@slack-client-dist-IncomingWebhook.js index 2a90acb00..484e4ba85 100644 --- a/support/jsdoc/@slack-client-dist-IncomingWebhook.js +++ b/support/jsdoc/@slack-client-dist-IncomingWebhook.js @@ -29,10 +29,3 @@ export class IncomingWebhookReadError { export class IncomingWebhookRequestError { } -/** - * @interface module:@slack/client/dist/IncomingWebhook.IncomingWebhookResult - * @property {string} text - */ -export class IncomingWebhookResult { -} - diff --git a/support/jsdoc/@slack-client-dist-KeepAlive.js b/support/jsdoc/@slack-client-dist-KeepAlive.js index efb8d92b3..3960308f4 100644 --- a/support/jsdoc/@slack-client-dist-KeepAlive.js +++ b/support/jsdoc/@slack-client-dist-KeepAlive.js @@ -8,8 +8,8 @@ * `recommend_reconnect` event. That event should be handled by tearing down the websocket connection and * opening a new one. * @extends EventEmitter - * @property {Boolean} [isMonitoring] Flag that indicates whether this object is still monitoring. - * @property {Boolean} [recommendReconnect] Flag that indicates whether recommend_reconnect event has been emitted and stop() has not been called. + * @property {boolean} isMonitoring Flag that indicates whether this object is still monitoring. + * @property {boolean} recommendReconnect Flag that indicates whether recommend_reconnect event has been emitted and stop() has not been called. */ export class KeepAlive { /** diff --git a/support/jsdoc/@slack-client-dist-WebClient.js b/support/jsdoc/@slack-client-dist-WebClient.js new file mode 100644 index 000000000..4c4a5831c --- /dev/null +++ b/support/jsdoc/@slack-client-dist-WebClient.js @@ -0,0 +1,13 @@ +/** + * @module @slack/client/dist/WebClient + */ + +/** + * @interface module:@slack/client/dist/WebClient.WebAPIRateLimitedError + * @extends module:@slack/client.CodedError + * @property {"slackclient_rate_limited_error"} code + * @property {number} retryAfter + */ +export class WebAPIRateLimitedError { +} + diff --git a/support/jsdoc/@slack-client-dist-logger.js b/support/jsdoc/@slack-client-dist-logger.js index 245f1c07e..f78bd17e4 100644 --- a/support/jsdoc/@slack-client-dist-logger.js +++ b/support/jsdoc/@slack-client-dist-logger.js @@ -44,6 +44,12 @@ export class Logger { warn() {} } +/** + * INTERNAL interface for getting or creating a named Logger + * @param {string} name + * @returns {module:@slack/client/dist/logger.Logger} + */ +export function getLogger() {} /** * INTERNAL function for transforming an external LoggerFunc type into the internal Logger interface * @param {string} name diff --git a/support/jsdoc/@slack-client-dist-methods.js b/support/jsdoc/@slack-client-dist-methods.js index 5f01f0498..72a2e4e67 100644 --- a/support/jsdoc/@slack-client-dist-methods.js +++ b/support/jsdoc/@slack-client-dist-methods.js @@ -2,12 +2,46 @@ * @module @slack/client/dist/methods */ +/** + * @type {Map} + * @constant + */ +export var cursorPaginationEnabledMethods +/** + * @interface module:@slack/client/dist/methods.AttachmentAction + * @property {string} [id] + * @property {module:@slack/client/dist/methods.Confirmation} [confirm] + * @property {string} [data_source] + * @property {number} [min_query_length] + * @property {string} [name] + * @property {Array} [options] + * @property {Array} [option_groups] + * @property {Array} [selected_options] + * @property {string} [style] + * @property {string} text + * @property {string} type + * @property {string} [value] + * @property {string} [url] + */ +export class AttachmentAction { +} + /** * @interface module:@slack/client/dist/methods.AuxiliaryArguments */ export class AuxiliaryArguments { } +/** + * @interface module:@slack/client/dist/methods.Confirmation + * @property {string} [dismiss_text] + * @property {string} [ok_text] + * @property {string} text + * @property {string} [title] + */ +export class Confirmation { +} + /** * @interface module:@slack/client/dist/methods.CursorPaginationEnabled * @property {number} [limit] @@ -22,6 +56,7 @@ export class CursorPaginationEnabled { * @property {string} callback_id * @property {Array} elements * @property {string} [submit_label] + * @property {boolean} [notify_on_cancel] */ export class Dialog { } @@ -56,7 +91,7 @@ export class LocaleAware { * @property {string} [footer] * @property {string} [footer_icon] * @property {string} [ts] - * @property {Array} [actions] + * @property {Array} [actions] * @property {string} [callback_id] * @property {Array<"pretext" | "text" | "fields">} [mrkdwn_in] */ @@ -69,6 +104,33 @@ export class MessageAttachment { export class Method { } +/** + * @interface module:@slack/client/dist/methods.OptionField + * @property {string} [description] + * @property {string} text + * @property {string} value + */ +export class OptionField { +} + +/** + * @interface module:@slack/client/dist/methods.Searchable + * @property {string} query + * @property {boolean} [highlight] + * @property {"score" | "timestamp"} sort + * @property {"asc" | "desc"} sort_dir + */ +export class Searchable { +} + +/** + * @interface module:@slack/client/dist/methods.SelectOption + * @property {string} label + * @property {string} value + */ +export class SelectOption { +} + /** * @interface module:@slack/client/dist/methods.TimelinePaginationEnabled * @property {string} [oldest] @@ -85,3 +147,11 @@ export class TimelinePaginationEnabled { export class TokenOverridable { } +/** + * @interface module:@slack/client/dist/methods.TraditionalPagingEnabled + * @property {number} [page] + * @property {number} [count] + */ +export class TraditionalPagingEnabled { +} + diff --git a/support/jsdoc/@slack-client-dist-util.js b/support/jsdoc/@slack-client-dist-util.js index 3904e2306..591e9eab3 100644 --- a/support/jsdoc/@slack-client-dist-util.js +++ b/support/jsdoc/@slack-client-dist-util.js @@ -2,6 +2,21 @@ * @module @slack/client/dist/util */ +/** + * Reduce an asynchronous iterable into a single value. + * @param {module:node_modules/typescript/lib/lib.esnext.asynciterable.AsyncIterable} iterable the async iterable to be reduced + * @param {callback} callbackfn a function that implements one step of the reduction + * @param {module:@slack/client/dist/util.U} initialValue the initial value for the accumulator + * @returns {Promise} + */ +export function awaitAndReduce() {} +/** + * Build a Promise that will resolve after the specified number of milliseconds. + * @param {number} ms milliseconds to wait + * @param {module:@slack/client/dist/util.T} value value for eventual resolution + * @returns {Promise} + */ +export function delay() {} /** * Returns the current User-Agent value for instrumentation * @returns {string} diff --git a/support/jsdoc/@slack-client.js b/support/jsdoc/@slack-client.js index 976e17185..b701d0ed9 100644 --- a/support/jsdoc/@slack-client.js +++ b/support/jsdoc/@slack-client.js @@ -1,4 +1,4 @@ -/** +/** * @module @slack/client */ @@ -10,6 +10,7 @@ * @property ReadError * @property HTTPError * @property PlatformError + * @property RateLimitedError * @property RTMSendWhileDisconnectedError * @property RTMSendWhileNotReadyError * @property RTMSendMessagePlatformError @@ -50,7 +51,7 @@ export class IncomingWebhook { * Send a notification to a conversation * @param {string | module:@slack/client.IncomingWebhookSendArguments} message the message (a simple string, or an object describing the message) * @function module:@slack/client.IncomingWebhook#send - * @returns {Promise} + * @returns {Promise} */ send() {} @@ -81,6 +82,13 @@ export class IncomingWebhook { export class IncomingWebhookDefaultArguments { } +/** + * @interface module:@slack/client.IncomingWebhookResult + * @property {string} text + */ +export class IncomingWebhookResult { +} + /** * @interface module:@slack/client.IncomingWebhookResultCallback */ @@ -150,12 +158,12 @@ export class RTMClient { * Generic method for sending an outgoing message of an arbitrary type. This method guards the higher-level methods * from concern of which state the client is in, because it places all messages into a queue. The tasks on the queue * will buffer until the client is in a state where they can be sent. - * + * * If the awaitReply parameter is set to true, then the returned Promise is resolved with the platform's * acknowledgement response. Not all message types will result in an acknowledgement response, so use this carefully. * This promise may be rejected with an error containing code=RTMNoReplyReceivedError if the client disconnects or * reconnects before recieving the acknowledgement response. - * + * * If the awaitReply parameter is set to false, then the returned Promise is resolved as soon as the message is sent * from the websocket. * @param {"undefined"} awaitReply whether to wait for an acknowledgement response from the platform before resolving the returned @@ -315,6 +323,10 @@ export class WebAPICallResult { * @extends module:@slack/client.CodedError * @property {"slackclient_http_error"} code * @property {Error} original + * @property {number} statusCode + * @property {string} statusMessage + * @property {module:http.IncomingHttpHeaders} headers + * @property {any} [body] */ export class WebAPIHTTPError { } @@ -354,7 +366,7 @@ export class WebAPIResultCallback { /** * A client for Slack's Web API - * + * * This client provides an alias for each {@link https://api.slack.com/methods|Web API method}. Each method is * a convenience wrapper for calling the {@link WebClient#apiCall} method using the method name as the first parameter. * @extends EventEmitter @@ -397,6 +409,8 @@ export class WebClient { * @property {module:@slack/client.RetryOptions} [retryConfig] * @property {"undefined" | "undefined" | module:http.Agent | module:@slack/client/dist/util.__type} [agent] * @property {module:@slack/client.TLSOptions} [tls] + * @property {number} [pageSize] + * @property {boolean} [rejectRateLimitedCalls] */ export class WebClientOptions { } diff --git a/test/typescript/sources/webclient-callback-type.ts b/test/typescript/sources/webclient-callback-type.ts new file mode 100644 index 000000000..75ab0031d --- /dev/null +++ b/test/typescript/sources/webclient-callback-type.ts @@ -0,0 +1,13 @@ +import { WebClient } from '../../../dist'; + +const web = new WebClient('TOKEN'); + +// calling a method with a callback function instead of returning a promise +web.chat.postMessage({ channel: 'CHANNEL', text: 'TEXT' }, (error, result) => { + // TODO: type assertion checks (when the library supports this feature) + if (error) { + console.log(error.code); + } + + console.log(result.ok); +}); diff --git a/test/typescript/test.ts b/test/typescript/test.ts index de174327c..7ec69ad3b 100644 --- a/test/typescript/test.ts +++ b/test/typescript/test.ts @@ -9,5 +9,8 @@ describe('typescript typings tests', () => { it('should export method argument types and enforce strictness in the right ways', () => { check([`${__dirname}/sources/method-argument-types.ts`], `${__dirname}/tsconfig-strict.json`); }); -}); + it('should properly type api calls with callback method parameters', () => { + check([`${__dirname}/sources/webclient-callback-type.ts`], `${__dirname}/tsconfig-strict.json`); + }); +}); diff --git a/test/typescript/tsconfig-strict.json b/test/typescript/tsconfig-strict.json index 000aa5b96..1260c83c4 100644 --- a/test/typescript/tsconfig-strict.json +++ b/test/typescript/tsconfig-strict.json @@ -8,6 +8,12 @@ "noUnusedParameters": true, "noImplicitReturns": true, "noFallthroughCasesInSwitch": true, - "moduleResolution": "node" + "moduleResolution": "node", + "lib": [ + "es5", + "es2015", + "es2016.array.include", + "esnext.asynciterable" + ] } } diff --git a/tsconfig.json b/tsconfig.json index 399b7997a..c1b020288 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -27,7 +27,8 @@ "lib": [ "es5", "es2015", - "es2016.array.include" + "es2016.array.include", + "esnext.asynciterable" ] }, "include": [