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": [