From 33a42af0bca77cb673c2055b2b2a7bb8c79c7398 Mon Sep 17 00:00:00 2001 From: Gautier Nilhcem Date: Wed, 24 Apr 2019 10:41:25 +0200 Subject: [PATCH 01/25] Use destructuring import for IncomingWebhook --- docs/_packages/webhook.md | 4 ++-- packages/webhook/README.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_packages/webhook.md b/docs/_packages/webhook.md index fabd8d067..f764dfa67 100644 --- a/docs/_packages/webhook.md +++ b/docs/_packages/webhook.md @@ -52,7 +52,7 @@ The webhook can be initialized with default arguments that are reused each time parameter to the constructor to set the default arguments. ```javascript -const IncomingWebhook = require('@slack/webhook'); +const { IncomingWebhook } = require('@slack/webhook'); const url = process.env.SLACK_WEBHOOK_URL; // Initialize with defaults @@ -72,7 +72,7 @@ Something interesting just happened in your app, so its time to send the notific the message. The method returns a `Promise` that resolves once the notification is sent. ```javascript -const IncomingWebhook = require('@slack/webhook'); +const { IncomingWebhook } = require('@slack/webhook'); const url = process.env.SLACK_WEBHOOK_URL; const webhook = new IncomingWebhook(url); diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 3837186f9..4fc37418e 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -50,7 +50,7 @@ The webhook can be initialized with default arguments that are reused each time parameter to the constructor to set the default arguments. ```javascript -const IncomingWebhook = require('@slack/webhook'); +const { IncomingWebhook } = require('@slack/webhook'); const url = process.env.SLACK_WEBHOOK_URL; // Initialize with defaults @@ -70,7 +70,7 @@ Something interesting just happened in your app, so its time to send the notific the message. The method returns a `Promise` that resolves once the notification is sent. ```javascript -const IncomingWebhook = require('@slack/webhook'); +const { IncomingWebhook } = require('@slack/webhook'); const url = process.env.SLACK_WEBHOOK_URL; const webhook = new IncomingWebhook(url); From 011d2dc68e501584f5051a0a909e4f1128bbc97e Mon Sep 17 00:00:00 2001 From: Felix Rieseberg Date: Tue, 28 May 2019 12:31:29 +0200 Subject: [PATCH 02/25] fix incorrect homepage for legacy wrapper --- packages/client/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/package.json b/packages/client/package.json index 8cae64cfc..24a86048f 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -30,7 +30,7 @@ "npm": ">= 5.5.1" }, "repository": "slackapi/node-slack-sdk", - "homepage": "https://slack.dev/node-slack-sdk/tutorials/migrating-to-v5/", + "homepage": "https://slack.dev/node-slack-sdk/tutorials/migrating-to-v5", "publishConfig": { "access": "public" }, From ddc0329562edca9c1f43108e4d058d13ad4f0fae Mon Sep 17 00:00:00 2001 From: David DeRemer Date: Fri, 31 May 2019 01:25:20 -0400 Subject: [PATCH 03/25] Add 'style' to Button type https://api.slack.com/docs/message-guidelines --- packages/types/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 7d949c342..6ae2cd215 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -127,6 +127,7 @@ export interface Button extends Action { text: PlainTextElement; value?: string; url?: string; + style?: 'danger' | 'default' | 'primary'; } export interface Overflow extends Action { From d0461f4b51be6f468c3677f15120e34b8fabefe5 Mon Sep 17 00:00:00 2001 From: David DeRemer Date: Wed, 5 Jun 2019 20:55:42 -0400 Subject: [PATCH 04/25] Update index.ts --- packages/types/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 6ae2cd215..8c3f52b41 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -127,7 +127,7 @@ export interface Button extends Action { text: PlainTextElement; value?: string; url?: string; - style?: 'danger' | 'default' | 'primary'; + style?: 'danger' | 'primary'; } export interface Overflow extends Action { From fb311b9cd109d71039b0760b2d1deb6dec675a77 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Tue, 11 Jun 2019 11:15:18 -0700 Subject: [PATCH 05/25] Convert `@slack/events-api` to TypeScript --- packages/events-api/.babelrc | 6 - packages/events-api/.eslintignore | 4 - packages/events-api/.mocharc.json | 3 + packages/events-api/.nycrc.json | 14 + packages/events-api/.vscode/settings.json | 3 + packages/events-api/package.json | 33 +- packages/events-api/src/.eslintrc | 7 - packages/events-api/src/adapter.js | 81 ----- .../test-adapter.js => src/adapter.spec.js} | 51 ++-- packages/events-api/src/adapter.ts | 156 ++++++++++ packages/events-api/src/http-handler.js | 216 ------------- .../http-handler.spec.js} | 84 +++--- packages/events-api/src/http-handler.ts | 285 ++++++++++++++++++ packages/events-api/src/index.js | 8 - packages/events-api/src/index.ts | 10 + packages/events-api/src/util.js | 9 - packages/events-api/src/util.ts | 9 + .../events-api/src/{verify.js => verify.ts} | 11 +- packages/events-api/test/.eslintrc | 10 - .../test/integration/test-adapter-options.js | 14 +- .../events-api/test/integration/test-basic.js | 18 +- packages/events-api/test/unit/test-index.js | 18 -- packages/events-api/tsconfig.json | 39 +++ packages/events-api/tslint.json | 70 +++++ packages/events-api/types/.gitkeep | 0 packages/events-api/types/tsscmp.d.ts | 4 + 26 files changed, 699 insertions(+), 464 deletions(-) delete mode 100644 packages/events-api/.babelrc delete mode 100644 packages/events-api/.eslintignore create mode 100644 packages/events-api/.mocharc.json create mode 100644 packages/events-api/.nycrc.json create mode 100644 packages/events-api/.vscode/settings.json delete mode 100644 packages/events-api/src/.eslintrc delete mode 100644 packages/events-api/src/adapter.js rename packages/events-api/{test/unit/test-adapter.js => src/adapter.spec.js} (71%) create mode 100644 packages/events-api/src/adapter.ts delete mode 100644 packages/events-api/src/http-handler.js rename packages/events-api/{test/unit/test-http-handler.js => src/http-handler.spec.js} (67%) create mode 100644 packages/events-api/src/http-handler.ts delete mode 100644 packages/events-api/src/index.js create mode 100644 packages/events-api/src/index.ts delete mode 100644 packages/events-api/src/util.js create mode 100644 packages/events-api/src/util.ts rename packages/events-api/src/{verify.js => verify.ts} (81%) delete mode 100644 packages/events-api/test/.eslintrc delete mode 100644 packages/events-api/test/unit/test-index.js create mode 100644 packages/events-api/tsconfig.json create mode 100644 packages/events-api/tslint.json create mode 100644 packages/events-api/types/.gitkeep create mode 100644 packages/events-api/types/tsscmp.d.ts diff --git a/packages/events-api/.babelrc b/packages/events-api/.babelrc deleted file mode 100644 index 432c76842..000000000 --- a/packages/events-api/.babelrc +++ /dev/null @@ -1,6 +0,0 @@ -{ - "presets": [ - "es2015", - "es2016" - ] -} diff --git a/packages/events-api/.eslintignore b/packages/events-api/.eslintignore deleted file mode 100644 index a0bd01b7e..000000000 --- a/packages/events-api/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -dist/**/*.js - -# babel-eslint parser is not functioning correcting for dynamic import syntax (`import()`) -# src/adapter.js diff --git a/packages/events-api/.mocharc.json b/packages/events-api/.mocharc.json new file mode 100644 index 000000000..6dbaa512b --- /dev/null +++ b/packages/events-api/.mocharc.json @@ -0,0 +1,3 @@ +{ + "require": ["ts-node/register", "source-map-support/register"] +} diff --git a/packages/events-api/.nycrc.json b/packages/events-api/.nycrc.json new file mode 100644 index 000000000..6c61b19dc --- /dev/null +++ b/packages/events-api/.nycrc.json @@ -0,0 +1,14 @@ +{ + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "**/*.spec.js" + ], + "reporter": ["lcov"], + "extension": [ + ".ts" + ], + "all": false, + "cache": true +} diff --git a/packages/events-api/.vscode/settings.json b/packages/events-api/.vscode/settings.json new file mode 100644 index 000000000..6de18e4ed --- /dev/null +++ b/packages/events-api/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "./node_modules/typescript/lib" +} diff --git a/packages/events-api/package.json b/packages/events-api/package.json index dee073bd1..2b531b6ad 100644 --- a/packages/events-api/package.json +++ b/packages/events-api/package.json @@ -36,40 +36,39 @@ }, "scripts": { "prepare": "npm run build", - "build": "babel src -d dist --source-maps both", - "lint": "eslint src test", - "test": "npm run build && nyc --reporter=html mocha test/**/*.js", - "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov -F eventsapi" + "build": "npm run build:clean && tsc", + "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", + "lint": "tslint --project .", + "test": "nyc mocha --config .mocharc.json src/*.spec.js test/integration/*.js", + "coverage": "codecov -F eventsapi --root=$PWD" }, "dependencies": { "debug": "^2.6.1", - "lodash.isstring": "^4.0.1", "raw-body": "^2.3.3", "tsscmp": "^1.0.6", "yargs": "^6.6.0" }, "devDependencies": { - "babel-cli": "^6.18.0", - "babel-eslint": "^7.1.1", - "babel-preset-es2015": "^6.18.0", - "babel-preset-es2016": "^6.16.0", - "chai": "^4.1.2", + "@types/debug": "^4.1.4", + "@types/node": "^12.0.6", + "@types/yargs": "^13.0.0", + "chai": "^4.2.0", "codecov": "^3.0.4", - "eslint": "^3.12.2", - "eslint-config-airbnb": "^13.0.0", - "eslint-config-airbnb-base": "^11.0.0", - "eslint-plugin-import": "^2.2.0", - "eslint-plugin-jsx-a11y": "^2.2.3", - "eslint-plugin-react": "^6.8.0", "express": "^4.14.0", "get-random-port": "0.0.1", "lodash.isfunction": "^3.0.8", - "mocha": "^5.2.0", + "mocha": "^6.1.4", "nop": "^1.0.0", "nyc": "^12.0.2", "proxyquire": "^1.7.10", + "shx": "^0.3.2", "sinon": "^4.5.0", + "source-map-support": "^0.5.12", "superagent": "^3.3.1", + "ts-node": "^8.2.0", + "tslint": "^5.17.0", + "tslint-config-airbnb": "^5.11.1", + "typescript": "^3.5.1", "uncaughtException": "^1.0.0" }, "optionalDependencies": { diff --git a/packages/events-api/src/.eslintrc b/packages/events-api/src/.eslintrc deleted file mode 100644 index f50a8278f..000000000 --- a/packages/events-api/src/.eslintrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "parser": "babel-eslint", - "extends": [ "airbnb-base" ], - "env": { - "node": true, - } -} diff --git a/packages/events-api/src/adapter.js b/packages/events-api/src/adapter.js deleted file mode 100644 index faeef5ae6..000000000 --- a/packages/events-api/src/adapter.js +++ /dev/null @@ -1,81 +0,0 @@ -import EventEmitter from 'events'; -import http from 'http'; -import isString from 'lodash.isstring'; -import debugFactory from 'debug'; -import { createHTTPHandler } from './http-handler'; - -const debug = debugFactory('@slack/events-api:adapter'); - -export class SlackEventAdapter extends EventEmitter { - constructor(signingSecret, options = {}) { - if (!isString(signingSecret)) { - throw new TypeError('SlackEventAdapter needs a signing secret'); - } - - super(); - - this.signingSecret = signingSecret; - this.includeBody = !!options.includeBody || false; - this.includeHeaders = !!options.includeHeaders || false; - this.waitForResponse = !!options.waitForResponse || false; - - debug('adapter instantiated - options: %o', { - includeBody: this.includeBody, - includeHeaders: this.includeHeaders, - waitForResponse: this.waitForResponse, - }); - } - - // TODO: options (like https) - createServer(path = '/slack/events') { - // NOTE: this is a workaround for a shortcoming of the System.import() tranform - return Promise.resolve().then(() => { - debug('server created - path: %s', path); - - return http.createServer(this.requestListener()); - }); - } - - start(port) { - return this.createServer() - .then(server => new Promise((resolve, reject) => { - this.server = server; - server.on('error', reject); - server.listen(port, () => resolve(server)); - debug('server started - port: %s', port); - })); - } - - stop() { - return new Promise((resolve, reject) => { - if (this.server) { - this.server.close((error) => { - delete this.server; - if (error) { - reject(error); - } else { - resolve(); - } - }); - } else { - reject(new Error('SlackEventAdapter cannot stop when it did not start a server')); - } - }); - } - - expressMiddleware(middlewareOptions = {}) { - const requestListener = this.requestListener(middlewareOptions); - return (req, res, next) => { // eslint-disable-line no-unused-vars - requestListener(req, res); - }; - } - - requestListener(middlewareOptions = {}) { - return createHTTPHandler(this, middlewareOptions); - } -} - -/** - * @alias module:adapter - */ -export default SlackEventAdapter; diff --git a/packages/events-api/test/unit/test-adapter.js b/packages/events-api/src/adapter.spec.js similarity index 71% rename from packages/events-api/test/unit/test-adapter.js rename to packages/events-api/src/adapter.spec.js index 4151b0a42..b8a04dc1b 100644 --- a/packages/events-api/test/unit/test-adapter.js +++ b/packages/events-api/src/adapter.spec.js @@ -1,29 +1,30 @@ -var http = require('http'); -var assert = require('chai').assert; -var sinon = require('sinon'); -var noop = require('nop'); -var getRandomPort = require('get-random-port'); -var EventEmitter = require('events'); -var systemUnderTest = require('../../dist/adapter'); -var createStreamRequest = require('../helpers').createStreamRequest; -var SlackEventAdapter = systemUnderTest.default; +require('mocha'); +const EventEmitter = require('events'); +const http = require('http'); +const { assert } = require('chai'); +const sinon = require('sinon'); +const noop = require('nop'); +const getRandomPort = require('get-random-port'); + +const { createStreamRequest } = require('../test/helpers'); +const { SlackEventAdapter } = require('./adapter'); // fixtures and test helpers -var workingSigningSecret = 'SIGNING_SECRET'; +const workingSigningSecret = 'SIGNING_SECRET'; describe('SlackEventAdapter', function () { describe('constructor', function () { it('should be an EventEmitter subclass', function () { - var adapter = new SlackEventAdapter(workingSigningSecret); + const adapter = new SlackEventAdapter(workingSigningSecret); assert(adapter instanceof EventEmitter); }); it('should fail without a signing secret', function () { assert.throws(function () { - var adapter = new SlackEventAdapter(); // eslint-disable-line no-unused-vars + const adapter = new SlackEventAdapter(); // eslint-disable-line no-unused-vars }, TypeError); }); it('should store the signing secret', function () { - var adapter = new SlackEventAdapter(workingSigningSecret); + const adapter = new SlackEventAdapter(workingSigningSecret); assert.equal(adapter.signingSecret, workingSigningSecret); }); }); @@ -42,7 +43,7 @@ describe('SlackEventAdapter', function () { describe('#start()', function () { beforeEach(function (done) { - var self = this; + const self = this; self.adapter = new SlackEventAdapter(workingSigningSecret); getRandomPort(function (error, port) { if (error) return done(error); @@ -54,7 +55,7 @@ describe('SlackEventAdapter', function () { return this.adapter.stop().catch(); }); it('should return a Promise for a started http.Server', function () { - var self = this; + const self = this; return this.adapter.start(self.portNumber).then(function (server) { // only works in node >= 5.7.0 // assert(server.listening); @@ -77,25 +78,25 @@ describe('SlackEventAdapter', function () { }); it('should return a function', function () { - var middleware = this.adapter.expressMiddleware(); + const middleware = this.adapter.expressMiddleware(); assert.isFunction(middleware); }); it('should emit on the adapter', function (done) { - var middleware = this.adapter.expressMiddleware(); - var emit = this.emit; - var ts = Math.floor(Date.now() / 1000); - var eventName = 'eventName'; - var event = { + const middleware = this.adapter.expressMiddleware(); + const emit = this.emit; + const ts = Math.floor(Date.now() / 1000); + const eventName = 'eventName'; + const event = { type: eventName, key: 'value' }; - var rawReq = { + const rawReq = { body: { event: event } }; - var req = createStreamRequest(workingSigningSecret, ts, JSON.stringify(rawReq.body)); - var res = sinon.stub({ + const req = createStreamRequest(workingSigningSecret, ts, JSON.stringify(rawReq.body)); + const res = sinon.stub({ setHeader: noop, end: noop }); @@ -117,7 +118,7 @@ describe('SlackEventAdapter', function () { this.adapter = new SlackEventAdapter(workingSigningSecret); }); it('should return a function', function () { - var requestListener = this.adapter.requestListener(); + const requestListener = this.adapter.requestListener(); assert.isFunction(requestListener); }); }); diff --git a/packages/events-api/src/adapter.ts b/packages/events-api/src/adapter.ts new file mode 100644 index 000000000..080eaa318 --- /dev/null +++ b/packages/events-api/src/adapter.ts @@ -0,0 +1,156 @@ +import EventEmitter from 'events'; // tslint:disable-line +import http, { IncomingMessage, ServerResponse } from 'http'; +import debugFactory from 'debug'; // tslint:disable-line +import { createHTTPHandler } from './http-handler'; + +const debug = debugFactory('@slack/events-api:adapter'); + +/** + * An adapter for Slack's Events API. + */ +export class SlackEventAdapter extends EventEmitter { + /** + * The token used to authenticate signed requests from Slack's Events API. + */ + public readonly signingSecret: string; + + /** + * Whether to include the API event bodies in adapter event consumers. + */ + public includeBody: boolean; + + /** + * Whether to include request headers in adapter event consumers. + */ + public includeHeaders: boolean; + + /** + * When `true`, prevents the adapter from responding by itself and leaves that up to consumers. + */ + public waitForResponse: boolean; + + /** + * The HTTP server this adapter might be running, created in {@link start}. + */ + private server?: http.Server; + + /** + * @param signingSecret - The token used to authenticate signed requests from Slack's Events API. + * @param opts.includeBody - TODO: + * @param opts.includeHeaders - TODO: + * @param opts.waitForResponse - TODO: + */ + constructor( + signingSecret: string | String, + { + includeBody = false, + includeHeaders = false, + waitForResponse = false, + }: EventAdapterOptions = {}, + ) { + if (typeof signingSecret !== 'string' && !(signingSecret instanceof String)) { + throw new TypeError('SlackEventAdapter needs a signing secret'); + } + + super(); + + this.signingSecret = signingSecret as string; + this.includeBody = includeBody; + this.includeHeaders = includeHeaders; + this.waitForResponse = waitForResponse; + + debug('adapter instantiated - options: %o', { + includeBody, + includeHeaders, + waitForResponse, + }); + } + + /** + * Creates an HTTP server to listen for event payloads. + * + * @param path - (UNUSED) The path to listen on. + */ + public createServer(path: string = '/slack/events'): Promise { + // TODO: options (like https) + // NOTE: this is a workaround for a shortcoming of the System.import() tranform + return Promise.resolve().then(() => { + debug('server created - path: %s', path); + + return http.createServer(this.requestListener()); + }); + } + + /** + * Starts a server on the specified port. + * + * @param port - The port number to listen on. + * @returns The {@link http.Server | server}. + */ + public start(port: number): Promise { + return this.createServer() + .then(server => new Promise((resolve, reject) => { + this.server = server; + server.on('error', reject); + server.listen(port, () => resolve(server)); + debug('server started - port: %s', port); + })); + } + + /** + * Stops the server started by {@link start}. + */ + public stop(): Promise { + return new Promise((resolve, reject) => { + if (this.server !== undefined) { + this.server.close((error) => { + delete this.server; + if (error !== undefined) { + reject(error); + } else { + resolve(); + } + }); + } else { + reject(new Error('SlackEventAdapter cannot stop when it did not start a server')); + } + }); + } + + /** + * Returns a middleware-compatible adapter. + * @param middlewareOptions - (UNUSED) + */ + public expressMiddleware( + middlewareOptions: object = {}, + ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { + const requestListener = this.requestListener(middlewareOptions); + return (req, res, _next) => { + requestListener(req, res); + }; + } + + /** + * Creates a request listener. + * + * @param middlewareOptions - (UNUSED) + */ + public requestListener(_middlewareOptions = {}): (req: IncomingMessage, res: ServerResponse) => void { + return createHTTPHandler(this); + } +} + +/** + * Options when constructing {@link SlackEventAdapter}. See {@link SlackEventAdapter}'s fields for more information on + * what each option does. + */ +export interface EventAdapterOptions { + includeBody?: boolean; + includeHeaders?: boolean; + waitForResponse?: boolean; +} + +/** + * @alias module:adapter + */ +export default SlackEventAdapter; diff --git a/packages/events-api/src/http-handler.js b/packages/events-api/src/http-handler.js deleted file mode 100644 index 52327ee43..000000000 --- a/packages/events-api/src/http-handler.js +++ /dev/null @@ -1,216 +0,0 @@ -import debugFactory from 'debug'; -import getRawBody from 'raw-body'; -import crypto from 'crypto'; -import timingSafeCompare from 'tsscmp'; -import { packageIdentifier } from './util'; - -export const errorCodes = { - SIGNATURE_VERIFICATION_FAILURE: 'SLACKHTTPHANDLER_REQUEST_SIGNATURE_VERIFICATION_FAILURE', - REQUEST_TIME_FAILURE: 'SLACKHTTPHANDLER_REQUEST_TIMELIMIT_FAILURE', - BODY_PARSER_NOT_PERMITTED: 'SLACKADAPTER_BODY_PARSER_NOT_PERMITTED_FAILURE', // moved constant from adapter -}; - -const responseStatuses = { - OK: 200, - FAILURE: 500, - REDIRECT: 302, - NOT_FOUND: 404, -}; - -const debug = debugFactory('@slack/events-api:http-handler'); - -/** - * Method to verify signature of requests - * - * @param {string} signingSecret - Signing secret used to verify request signature - * @param {string} requestSignature - Signature from request 'x-slack-signature' header - * @param {number} requestTimestamp - Timestamp from request 'x-slack-request-timestamp' header - * @param {string} body - Raw body string - * @returns {boolean} Indicates if request is verified - */ -export function verifyRequestSignature({ - signingSecret, requestSignature, requestTimestamp, body, -}) { - // Divide current date to match Slack ts format - // Subtract 5 minutes from current time - const fiveMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 5); - - if (requestTimestamp < fiveMinutesAgo) { - debug('request is older than 5 minutes'); - const error = new Error('Slack request signing verification outdated'); - error.code = errorCodes.REQUEST_TIME_FAILURE; - throw error; - } - - const hmac = crypto.createHmac('sha256', signingSecret); - const [version, hash] = requestSignature.split('='); - hmac.update(`${version}:${requestTimestamp}:${body}`); - - if (!timingSafeCompare(hash, hmac.digest('hex'))) { - debug('request signature is not valid'); - const error = new Error('Slack request signing verification failed'); - error.code = errorCodes.SIGNATURE_VERIFICATION_FAILURE; - throw error; - } - - debug('request signing verification success'); - return true; -} - -export function createHTTPHandler(adapter) { - const poweredBy = packageIdentifier(); - - /** - * Binds a specific response instance to the function that works like a - * completion handler - * - * @param {Object} res - Response object - * @returns {Function} Returns a function used to send response - */ - function sendResponse(res) { - // This function is the completion handler for sending a response to an event. It can either - // be invoked by automatically or by the user (when using the `waitForResponse` option). - return function _sendResponse(err, responseOptions) { - debug('sending response - error: %s, responseOptions: %o', err, responseOptions); - // Deal with errors up front - if (err) { - if (err.status) { - res.statusCode = err.status; - } else if (err.code === errorCodes.SIGNATURE_VERIFICATION_FAILURE || - err.code === errorCodes.REQUEST_TIME_FAILURE) { - res.statusCode = responseStatuses.NOT_FOUND; - } else { - res.statusCode = responseStatuses.FAILURE; - } - } else { - // First determine the response status - if (responseOptions) { - if (responseOptions.failWithNoRetry) { - res.statusCode = responseStatuses.FAILURE; - } else if (responseOptions.redirectLocation) { - res.statusCode = responseStatuses.REDIRECT; - } else { - // URL Verification - res.statusCode = responseStatuses.OK; - } - } else { - res.statusCode = responseStatuses.OK; - } - - // Next determine the response headers - if (responseOptions && responseOptions.failWithNoRetry) { - res.setHeader('X-Slack-No-Retry', '1'); - } - res.setHeader('X-Slack-Powered-By', poweredBy); - } - - // Lastly, send the response - if (responseOptions && responseOptions.content) { - res.end(responseOptions.content); - } else { - res.end(); - } - }; - } - - /** - * Abstracts error handling. - * - * @param {Error} error - * @param {Function} respond - */ - function handleError(error, respond) { - debug('handling error - message: %s, code: %s', error.message, error.code); - try { - if (adapter.waitForResponse) { - adapter.emit('error', error, respond); - } else if (process.env.NODE_ENV === 'development') { - adapter.emit('error', error); - respond({ status: 500 }, { content: error.message }); - } else { - adapter.emit('error', error); - respond(error); - } - } catch (userError) { - process.nextTick(() => { throw userError; }); - } - } - - /** - * Request listener used to handle Slack requests and send responses and - * verify request signatures - * - * @param {Object} req - Request object - * @param {Object} res - Response object - */ - return function slackEventRequestListener(req, res) { - debug('request recieved - method: %s, path: %s', req.method, req.url); - - // Bind a response function to this request's respond object. - const respond = sendResponse(res); - - // If parser is being used and we don't receive the raw payload via `rawBody`, - // we can't verify request signature - if (req.body && !req.rawBody) { - const error = new Error('Parsing request body prohibits request signature verification'); - error.code = errorCodes.BODY_PARSER_NOT_PERMITTED; - handleError(error, respond); - return; - } - - // Some serverless cloud providers (e.g. Google Firebase Cloud Functions) might populate - // the request with a bodyparser before it can be populated by the SDK. - // To prevent throwing an error here, we check the `rawBody` field before parsing the request - // through the `raw-body` module (see Issue #85 - https://github.com/slackapi/node-slack-events-api/issues/85) - let parseRawBody; - if (req.rawBody) { - debug('Parsing request with a rawBody attribute'); - parseRawBody = new Promise((resolve) => { - resolve(req.rawBody); - }); - } else { - debug('Parsing raw request'); - parseRawBody = getRawBody(req); - } - - parseRawBody - .then((r) => { - const rawBody = r.toString(); - if (verifyRequestSignature({ - signingSecret: adapter.signingSecret, - requestSignature: req.headers['x-slack-signature'], - requestTimestamp: req.headers['x-slack-request-timestamp'], - body: rawBody, - })) { - // Request signature is verified - // Parse raw body - const body = JSON.parse(rawBody); - - // Handle URL verification challenge - if (body.type === 'url_verification') { - debug('handling url verification'); - respond(null, { content: body.challenge }); - return; - } - - const emitArguments = [body.event]; - if (adapter.includeBody) { - emitArguments.push(body); - } - if (adapter.includeHeaders) { - emitArguments.push(req.headers); - } - if (adapter.waitForResponse) { - emitArguments.push(respond); - } else { - respond(); - } - - debug('emitting event - type: %s, arguments: %o', body.event.type, emitArguments); - adapter.emit(body.event.type, ...emitArguments); - } - }).catch((error) => { - handleError(error, respond); - }); - }; -} diff --git a/packages/events-api/test/unit/test-http-handler.js b/packages/events-api/src/http-handler.spec.js similarity index 67% rename from packages/events-api/test/unit/test-http-handler.js rename to packages/events-api/src/http-handler.spec.js index 8a5d5f4dd..1b2ca18d3 100644 --- a/packages/events-api/test/unit/test-http-handler.js +++ b/packages/events-api/src/http-handler.spec.js @@ -1,19 +1,17 @@ -var assert = require('chai').assert; -var sinon = require('sinon'); -var proxyquire = require('proxyquire'); -var createRequest = require('../helpers').createRequest; -var createRawBodyRequest = require('../helpers').createRawBodyRequest; -var getRawBodyStub = sinon.stub(); -var systemUnderTest = proxyquire('../../dist/http-handler', { - 'raw-body': getRawBodyStub -}); -var createHTTPHandler = systemUnderTest.createHTTPHandler; -var verifyRequestSignature = systemUnderTest.verifyRequestSignature; +require('mocha'); +const { assert } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +const { createRequest, createRawBodyRequest } = require('../test/helpers'); + +const getRawBodyStub = sinon.stub(); +const { createHTTPHandler, verifyRequestSignature } = proxyquire('./http-handler', { 'raw-body': getRawBodyStub }); // fixtures -var correctSigningSecret = 'SIGNING_SECRET'; -var correctRawBody = '{"type":"event_callback","event":{"type":"reaction_added",' + -'"user":"U123","item":{"type":"message","channel":"C123"}}}'; +const correctSigningSecret = 'SIGNING_SECRET'; +const correctRawBody = '{"type":"event_callback","event":{"type":"reaction_added","user":"U123","item":{"type":"messa' + + 'ge","channel":"C123"}}}'; describe('http-handler', function () { beforeEach(function () { @@ -22,8 +20,8 @@ describe('http-handler', function () { describe('verifyRequestSignature', function () { it('should return true for a valid request', function () { - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); - var isVerified = verifyRequestSignature({ + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const isVerified = verifyRequestSignature({ signingSecret: correctSigningSecret, requestTimestamp: req.headers['x-slack-request-timestamp'], requestSignature: req.headers['x-slack-signature'], @@ -34,7 +32,7 @@ describe('http-handler', function () { }); it('should throw for a request signed with a different secret', function () { - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); assert.throws(() => verifyRequestSignature({ signingSecret: 'INVALID_SECRET', requestTimestamp: req.headers['x-slack-request-timestamp'], @@ -61,9 +59,9 @@ describe('http-handler', function () { }); it('should verify a correct signing secret', function (done) { - var emit = this.emit; - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const emit = this.emit; + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); emit.resolves({ status: 200 }); getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { @@ -74,9 +72,9 @@ describe('http-handler', function () { }); it('should verify a correct signing secret for a request with rawBody attribute', function (done) { - var emit = this.emit; - var res = this.res; - var req = createRawBodyRequest(correctSigningSecret, this.correctDate, correctRawBody); + const emit = this.emit; + const res = this.res; + const req = createRawBodyRequest(correctSigningSecret, this.correctDate, correctRawBody); emit.resolves({ status: 200 }); getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { @@ -87,8 +85,8 @@ describe('http-handler', function () { }); it('should fail request signing verification for a request with a body but no rawBody', function (done) { - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); req.body = {}; getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { @@ -99,8 +97,8 @@ describe('http-handler', function () { }); it('should fail request signing verification with an incorrect signing secret', function (done) { - var res = this.res; - var req = createRequest('INVALID_SECRET', this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest('INVALID_SECRET', this.correctDate, correctRawBody); getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { assert.equal(res.statusCode, 404); @@ -110,8 +108,8 @@ describe('http-handler', function () { }); it('should fail request signing verification when a request has body and no rawBody attribute', function (done) { - var res = this.res; - var req = createRequest('INVALID_SECRET', this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest('INVALID_SECRET', this.correctDate, correctRawBody); getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { assert.equal(res.statusCode, 404); @@ -121,9 +119,9 @@ describe('http-handler', function () { }); it('should fail request signing verification with old timestamp', function (done) { - var res = this.res; - var sixMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 6); - var req = createRequest(correctSigningSecret, sixMinutesAgo, correctRawBody); + const res = this.res; + const sixMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 6); + const req = createRequest(correctSigningSecret, sixMinutesAgo, correctRawBody); getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { assert.equal(res.statusCode, 404); @@ -133,8 +131,8 @@ describe('http-handler', function () { }); it('should handle unexpected error', function (done) { - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); getRawBodyStub.rejects(new Error('test error')); res.end.callsFake(function (result) { assert.equal(res.statusCode, 500); @@ -145,8 +143,8 @@ describe('http-handler', function () { }); it('should provide message with unexpected errors in development', function (done) { - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); process.env.NODE_ENV = 'development'; getRawBodyStub.rejects(new Error('test error')); res.end.callsFake(function (result) { @@ -159,9 +157,9 @@ describe('http-handler', function () { }); it('should set an identification header in its responses', function (done) { - var emit = this.emit; - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const emit = this.emit; + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); emit.resolves({ status: 200 }); getRawBodyStub.resolves(Buffer.from(correctRawBody)); res.end.callsFake(function () { @@ -172,10 +170,10 @@ describe('http-handler', function () { }); it('should respond to url verification requests', function (done) { - var res = this.res; - var emit = this.emit; - var urlVerificationBody = '{"type":"url_verification","challenge": "TEST_CHALLENGE"}'; - var req = createRequest(correctSigningSecret, this.correctDate, urlVerificationBody); + const res = this.res; + const emit = this.emit; + const urlVerificationBody = '{"type":"url_verification","challenge": "TEST_CHALLENGE"}'; + const req = createRequest(correctSigningSecret, this.correctDate, urlVerificationBody); getRawBodyStub.resolves(Buffer.from(urlVerificationBody)); res.end.callsFake(function () { assert(emit.notCalled); diff --git a/packages/events-api/src/http-handler.ts b/packages/events-api/src/http-handler.ts new file mode 100644 index 000000000..be2cd502d --- /dev/null +++ b/packages/events-api/src/http-handler.ts @@ -0,0 +1,285 @@ +/* tslint:disable:import-name */ +import debugFactory from 'debug'; +import getRawBody from 'raw-body'; +import crypto from 'crypto'; +import timingSafeCompare from 'tsscmp'; +import { packageIdentifier } from './util'; +import SlackEventAdapter from './adapter'; +import { IncomingMessage, ServerResponse } from 'http'; + +const debug = debugFactory('@slack/events-api:http-handler'); + +/** + * Verifies the signature of a request. + * + * @remarks + * See [Verifying requests from Slack](https://api.slack.com/docs/verifying-requests-from-slack#sdk_support) for more + * information. + * + * @param params - See {@link VerifyRequestSignatureParams}. + * @returns `true` when the signature is valid. + * @throws {CodedError} - Signature is invalid. + */ +export function verifyRequestSignature({ + signingSecret, requestSignature, requestTimestamp, body, +}: VerifyRequestSignatureParams): true { + // convert the current time to seconds (to match the API's `ts` format), then subtract 5 minutes' worth of seconds. + const fiveMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 5); + + if (requestTimestamp < fiveMinutesAgo) { + debug('request is older than 5 minutes'); + throw errorWithCode(new Error('Slack request signing verification outdated'), ErrorCode.RequestTimeFailure); + } + + const hmac = crypto.createHmac('sha256', signingSecret); + const [version, hash] = requestSignature.split('='); + hmac.update(`${version}:${requestTimestamp}:${body}`); + + if (!timingSafeCompare(hash, hmac.digest('hex'))) { + debug('request signature is not valid'); + throw errorWithCode(new Error('Slack request signing verification failed'), ErrorCode.SignatureVerificationFailure); + } + + debug('request signing verification success'); + return true; +} + +export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { + const poweredBy = packageIdentifier(); + + /** + * Binds a response handler to the given response. + * + * @param res - The response object. + * @returns The responder funciton bound to the input response. + */ + function sendResponse(res: ServerResponse): ResponseHandler { + // This function is the completion handler for sending a response to an event. It can either + // be invoked by automatically or by the user (when using the `waitForResponse` option). + return (err, responseOptions) => { + debug('sending response - error: %s, responseOptions: %o', err, responseOptions); + // Deal with errors up front + if (err !== undefined) { + if ('status' in err) { + res.statusCode = err.status; + } else if (err.code === ErrorCode.SignatureVerificationFailure || + err.code === ErrorCode.RequestTimeFailure) { + res.statusCode = ResponseStatus.NotFound; + } else { + res.statusCode = ResponseStatus.Failure; + } + } else { + // First determine the response status + if (responseOptions !== undefined) { + if (responseOptions.failWithNoRetry) { + res.statusCode = ResponseStatus.Failure; + } else if (responseOptions.redirectLocation) { + res.statusCode = ResponseStatus.Redirect; + } else { + // URL Verification + res.statusCode = ResponseStatus.Ok; + } + } else { + res.statusCode = ResponseStatus.Ok; + } + + // Next determine the response headers + if (responseOptions !== undefined && responseOptions.failWithNoRetry) { + res.setHeader('X-Slack-No-Retry', '1'); + } + res.setHeader('X-Slack-Powered-By', poweredBy); + } + + // Lastly, send the response + if (responseOptions !== undefined && responseOptions.content) { + res.end(responseOptions.content); + } else { + res.end(); + } + }; + } + + /** + * Handles making responses for errors. + * + * @param error - The error that occurred. + * @param respond - The {@link ResponseHandler | response handler}. + */ + function handleError(error: CodedError, respond: ResponseHandler): void { + debug('handling error - message: %s, code: %s', error.message, error.code); + try { + if (adapter.waitForResponse) { + adapter.emit('error', error, respond); + } else if (process.env.NODE_ENV === 'development') { + adapter.emit('error', error); + respond({ status: ResponseStatus.Failure }, { content: error.message }); + } else { + adapter.emit('error', error); + respond(error); + } + } catch (userError) { + process.nextTick(() => { throw userError; }); + } + } + + /** + * Request listener used to handle Slack requests and send responses and + * verify request signatures + * + * @param req - The incoming request. + * @param res - The outgoing response. + */ + return (req, res) => { + debug('request recieved - method: %s, path: %s', req.method, req.url); + + // Bind a response function to this request's respond object. + const respond = sendResponse(res); + + // If parser is being used and we don't receive the raw payload via `rawBody`, + // we can't verify request signature + if (req.body !== undefined && req.rawBody === undefined) { + handleError( + errorWithCode( + new Error('Parsing request body prohibits request signature verification'), + ErrorCode.BodyParserNotPermitted, + ), + respond, + ); + return; + } + + // Some serverless cloud providers (e.g. Google Firebase Cloud Functions) might populate + // the request with a bodyparser before it can be populated by the SDK. + // To prevent throwing an error here, we check the `rawBody` field before parsing the request + // through the `raw-body` module (see Issue #85 - https://github.com/slackapi/node-slack-events-api/issues/85) + let parseRawBody: Promise; + if (req.rawBody !== undefined) { + debug('Parsing request with a rawBody attribute'); + parseRawBody = Promise.resolve(req.rawBody); + } else { + debug('Parsing raw request'); + parseRawBody = getRawBody(req); + } + + parseRawBody + .then((bodyBuf) => { + const rawBody = bodyBuf.toString(); + if (verifyRequestSignature({ + signingSecret: adapter.signingSecret, + requestSignature: req.headers['x-slack-signature'] as string, + requestTimestamp: parseInt(req.headers['x-slack-request-timestamp'] as string, 10), + body: rawBody, + })) { + // Request signature is verified + // Parse raw body + const body = JSON.parse(rawBody); + + // Handle URL verification challenge + if (body.type === 'url_verification') { + debug('handling url verification'); + respond(undefined, { content: body.challenge }); + return; + } + + const emitArguments = [body.event]; + if (adapter.includeBody) { + emitArguments.push(body); + } + if (adapter.includeHeaders) { + emitArguments.push(req.headers); + } + if (adapter.waitForResponse) { + emitArguments.push(respond); + } else { + respond(); + } + + debug('emitting event - type: %s, arguments: %o', body.event.type, emitArguments); + adapter.emit(body.event.type, ...emitArguments); + } + }).catch((error) => { + handleError(error, respond); + }); + }; +} + +/** Some HTTP response statuses. */ +enum ResponseStatus { + Ok = 200, + Redirect = 302, + NotFound = 404, + Failure = 500, +} + +type HTTPHandler = (req: IncomingMessage & { body?: string, rawBody?: Buffer }, res: ServerResponse) => void; + +/** + * A response handler returned by `sendResponse`. + */ +type ResponseHandler = (err?: (Error & Partial>) | { status: number }, responseOptions?: { + failWithNoRetry?: boolean; + redirectLocation?: boolean; + content?: any; +}) => void; + +/** + * Parameters for calling {@link verifyRequestSignature}. + */ +export interface VerifyRequestSignatureParams { + /** + * The signing secret used to verify request signature. + */ + signingSecret: string; + + /** + * Signature from the `X-Slack-Signature` header. + */ + requestSignature: string; + + /** + * Timestamp from the `X-Slack-Request-Timestamp` header. + */ + requestTimestamp: number; + + /** + * Full, raw body string. + */ + body: string; +} + +/** + * A dictionary of codes for errors produced by this package. + */ +export enum ErrorCode { + SignatureVerificationFailure = 'SLACKHTTPHANDLER_REQUEST_SIGNATURE_VERIFICATION_FAILURE', + RequestTimeFailure = 'SLACKHTTPHANDLER_REQUEST_TIMELIMIT_FAILURE', + BodyParserNotPermitted = 'SLACKADAPTER_BODY_PARSER_NOT_PERMITTED_FAILURE', +} + +/** + * All errors produced by this package are regular + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error | Error} objects with + * an extra {@link CodedError.code | `error`} field. + */ +export interface CodedError extends Error { + /** + * What kind of error occurred. + */ + code: ErrorCode; +} + +/** + * Factory for producing a {@link CodedError} from a generic error. + */ +function errorWithCode(error: Error, code: ErrorCode): CodedError { + const codedError = error as CodedError; + codedError.code = code; + return codedError; +} + +// legacy export +export const errorCodes = { + SIGNATURE_VERIFICATION_FAILURE: ErrorCode.SignatureVerificationFailure, + REQUEST_TIME_FAILURE: ErrorCode.RequestTimeFailure, + BODY_PARSER_NOT_PERMITTED: ErrorCode.BodyParserNotPermitted, +}; diff --git a/packages/events-api/src/index.js b/packages/events-api/src/index.js deleted file mode 100644 index 059800063..000000000 --- a/packages/events-api/src/index.js +++ /dev/null @@ -1,8 +0,0 @@ -import { errorCodes as adapterErrorCodes, SlackEventAdapter } from './adapter'; - -export { verifyRequestSignature } from './http-handler'; -export const errorCodes = adapterErrorCodes; - -export function createEventAdapter(signingSecret, options) { - return new SlackEventAdapter(signingSecret, options); -} diff --git a/packages/events-api/src/index.ts b/packages/events-api/src/index.ts new file mode 100644 index 000000000..941be3ddf --- /dev/null +++ b/packages/events-api/src/index.ts @@ -0,0 +1,10 @@ +import { SlackEventAdapter, EventAdapterOptions } from './adapter'; + +export { verifyRequestSignature, errorCodes } from './http-handler'; + +/** + * Creates a new {@link SlackEventAdapter}. + */ +export function createEventAdapter(signingSecret: string, options?: EventAdapterOptions): SlackEventAdapter { + return new SlackEventAdapter(signingSecret, options); +} diff --git a/packages/events-api/src/util.js b/packages/events-api/src/util.js deleted file mode 100644 index 6775a00a6..000000000 --- a/packages/events-api/src/util.js +++ /dev/null @@ -1,9 +0,0 @@ -import os from 'os'; -import pkg from '../package.json'; - -// TODO: expose an API to extend this -// there will potentially be more named exports in this file -// eslint-disable-next-line import/prefer-default-export -export function packageIdentifier() { - return `${pkg.name.replace('/', ':')}/${pkg.version} ${os.platform()}/${os.release()} node/${process.version.replace('v', '')}`; -} diff --git a/packages/events-api/src/util.ts b/packages/events-api/src/util.ts new file mode 100644 index 000000000..373d91ff0 --- /dev/null +++ b/packages/events-api/src/util.ts @@ -0,0 +1,9 @@ +import os from 'os'; +const pkg = require('../package.json'); // tslint:disable-line + +// TODO: expose an API to extend this +// there will potentially be more named exports in this file +export function packageIdentifier(): string { + return `${pkg.name.replace('/', ':')}/${pkg.version} ${os.platform()}/${os.release()} ` + + `node/${process.version.replace('v', '')}`; +} diff --git a/packages/events-api/src/verify.js b/packages/events-api/src/verify.ts similarity index 81% rename from packages/events-api/src/verify.js rename to packages/events-api/src/verify.ts index c8bbbc213..8a982a01b 100755 --- a/packages/events-api/src/verify.js +++ b/packages/events-api/src/verify.ts @@ -2,6 +2,7 @@ import yargs from 'yargs'; import { createEventAdapter } from './index'; +import { AddressInfo } from 'net'; const argv = yargs .options({ @@ -13,13 +14,15 @@ const argv = yargs }, path: { alias: 'p', - describe: 'The path (part of URL after hostname and port) that resolves to your Request URL in the App management page', + describe: 'The path (part of URL after hostname and port) that resolves to your Request URL in the App ' + + 'management page', default: '/slack/events', type: 'string', }, port: { alias: 'l', - describe: 'The local port for the HTTP server. The development proxy should be configured to forward to this port.', + describe: 'The local port for the HTTP server. The development proxy should be configured to forward to this ' + + 'port.', default: 3000, type: 'number', }, @@ -29,13 +32,12 @@ const argv = yargs const slackEvents = createEventAdapter(argv.secret); -/* eslint-disable no-console */ slackEvents .createServer(argv.path) .then(server => new Promise((resolve, reject) => { server.on('error', reject); server.listen(argv.port, () => { - const { address, port } = server.address(); + const { address, port } = server.address() as AddressInfo; console.log(`The verification server is now listening at the URL: http://${address}:${port}${argv.path}`); resolve(); }); @@ -43,4 +45,3 @@ slackEvents .catch((error) => { console.error(`The verification server failed to start. error: ${error.message}`); }); -/* eslint-enable no-console */ diff --git a/packages/events-api/test/.eslintrc b/packages/events-api/test/.eslintrc deleted file mode 100644 index e90db91b4..000000000 --- a/packages/events-api/test/.eslintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "airbnb-base/legacy", - "env": { - "mocha": true - }, - "parser": "babel-eslint", - "rules": { - "func-names": "off" - } -} diff --git a/packages/events-api/test/integration/test-adapter-options.js b/packages/events-api/test/integration/test-adapter-options.js index 9761558b3..599bcf681 100644 --- a/packages/events-api/test/integration/test-adapter-options.js +++ b/packages/events-api/test/integration/test-adapter-options.js @@ -1,10 +1,12 @@ -var assert = require('assert'); -var request = require('superagent'); -var createRequestSignature = require('../helpers').createRequestSignature; -var createEventAdapter = require('../../dist').createEventAdapter; +require('mocha'); +const assert = require('assert'); +const request = require('superagent'); +const isFunction = require('lodash.isfunction'); -var isFunction = require('lodash.isfunction'); -var correctSigningSecret = 'SIGNING_SECRET'; +const { createEventAdapter } = require('../../src/'); +const { createRequestSignature } = require('../helpers'); + +const correctSigningSecret = 'SIGNING_SECRET'; describe('when using the waitForResponse option', function () { beforeEach(function () { diff --git a/packages/events-api/test/integration/test-basic.js b/packages/events-api/test/integration/test-basic.js index 427736ce8..4e042539b 100644 --- a/packages/events-api/test/integration/test-basic.js +++ b/packages/events-api/test/integration/test-basic.js @@ -1,13 +1,13 @@ -var http = require('http'); -var assert = require('assert'); -var express = require('express'); -var request = require('superagent'); -var createEventAdapter = require('../../dist').createEventAdapter; -var createRequestSignature = require('../helpers').createRequestSignature; -var errorCodes = require('../../dist/http-handler').errorCodes; -var uncaughtException = require('uncaughtException'); +require('mocha'); +const http = require('http'); +const assert = require('assert'); +const express = require('express'); +const request = require('superagent'); +const uncaughtException = require('uncaughtException'); -var helpers = require('../helpers'); +const { createEventAdapter, errorCodes } = require('../../src/'); +const helpers = require('../helpers'); +const { createRequestSignature } = helpers; describe('when using middleware inside your own express application', function () { beforeEach(function (done) { diff --git a/packages/events-api/test/unit/test-index.js b/packages/events-api/test/unit/test-index.js deleted file mode 100644 index b42c0af67..000000000 --- a/packages/events-api/test/unit/test-index.js +++ /dev/null @@ -1,18 +0,0 @@ -'use strict'; - -var assert = require('chai').assert; -var library = require('../../dist'); - -describe('@slack/events-api', function () { - it('should export "verifyRequestSignature"', function () { - assert.property(library, 'verifyRequestSignature'); - }); - - it('should export "createEventAdapter"', function () { - assert.property(library, 'createEventAdapter'); - }); - - it('should export "errorCodes"', function () { - assert.property(library, 'errorCodes'); - }); -}); diff --git a/packages/events-api/tsconfig.json b/packages/events-api/tsconfig.json new file mode 100644 index 000000000..c21aa5718 --- /dev/null +++ b/packages/events-api/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "*": ["./types/*"] + }, + "esModuleInterop" : true, + + // Not using this setting because its only used to require the package.json file, and that would change the + // structure of the files in the dist directory because package.json is not located inside src. It would be nice + // to use import instead of require(), but its not worth the tradeoff of restructuring the build (for now). + // "resolveJsonModule": true, + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "src/**/*.spec.js", + "src/**/*.js" + ], + "jsdoc": { + "out": "support/jsdoc", + "access": "public" + } +} diff --git a/packages/events-api/tslint.json b/packages/events-api/tslint.json new file mode 100644 index 000000000..6937e61da --- /dev/null +++ b/packages/events-api/tslint.json @@ -0,0 +1,70 @@ +{ + "extends": ["tslint-config-airbnb"], + "rules": { + /* modifications to base config */ + // adds statements, members, and elements to the base config + "align": [true, "parameters", "arguments", "statements", "members", "elements"], + // adds number of spaces so auto-fixing will work + "indent": [true, "spaces", 2], + // increase value from 100 in base config to 120 + "max-line-length": [true, 120], + // adds avoid-escape and avoid-template + "quotemark": [true, "single", "avoid-escape", "avoid-template"], + // adds ban-keywords and allow-leading-underscores + // once this gets implemented, we should incorporate it: https://github.com/palantir/tslint/issues/3442 + "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], + // adds check-module, check-type, check-rest-spread, check-typecast, check-type-operator + "whitespace": [true, + "check-branch", + "check-decl", + "check-operator", + "check-preblock", + "check-type", + "check-module", + "check-separator", + "check-rest-spread", + "check-typecast", + "check-type-operator" + ], + + /* not used in base config */ + "await-promise": true, + "ban-comma-operator": true, + // Disabling the following rule because of https://github.com/palantir/tslint/issues/4493 + // "completed-docs": true, + "interface-over-type-literal": true, + "jsdoc-format": [true, "check-multiline-start"], + "member-access": [true, "check-accessor"], + "no-duplicate-imports": true, + "no-duplicate-switch-case": true, + "no-duplicate-variable": true, + "no-dynamic-delete": true, + "no-empty": true, + "no-floating-promises": true, + "no-for-in-array": true, + "no-implicit-dependencies": true, + "no-object-literal-type-assertion": true, + "no-redundant-jsdoc": true, + "no-require-imports": true, + "no-return-await": true, + "no-submodule-imports": true, + "no-this-assignment": true, + "no-unused-expression": true, + "no-var-requires": true, + "one-line": [true, "check-else", "check-whitespace", "check-open-brace", "check-catch", "check-finally"], + "strict-boolean-expressions": [true, "allow-boolean-or-undefined"], + "typedef": [true, "call-signature"], + "typedef-whitespace": [true, { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }] + // TODO: find a rule similar to https://palantir.github.io/tslint/rules/no-construct/, except it bans those types + // from interfaces (e.g. a function that returns Boolean is an error, it should return boolean) + }, + "linterOptions": { + "format": "verbose" + } +} diff --git a/packages/events-api/types/.gitkeep b/packages/events-api/types/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/events-api/types/tsscmp.d.ts b/packages/events-api/types/tsscmp.d.ts new file mode 100644 index 000000000..2de5e631c --- /dev/null +++ b/packages/events-api/types/tsscmp.d.ts @@ -0,0 +1,4 @@ +declare module 'tsscmp' { + function timingSafeCompare(sessionToken: string, givenToken: string): boolean; + export = timingSafeCompare; +} From afefd1294eb703fbd27f79112e942a55d87d410a Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Wed, 12 Jun 2019 14:46:23 -0700 Subject: [PATCH 06/25] Convert `@slack/interactive-messages` to TypeScript --- packages/interactive-messages/.babelrc | 12 - packages/interactive-messages/.eslintignore | 1 - packages/interactive-messages/.mocharc.json | 3 + packages/interactive-messages/.nycrc.json | 14 + packages/interactive-messages/package.json | 28 +- packages/interactive-messages/src/.eslintrc | 10 - .../test-adapter.js => src/adapter.spec.js} | 320 +++++++------- .../src/{adapter.js => adapter.ts} | 418 +++++++++--------- packages/interactive-messages/src/errors.ts | 30 ++ .../http-handler.spec.js} | 94 ++-- .../src/{http-handler.js => http-handler.ts} | 115 +++-- .../src/{index.js => index.ts} | 10 +- packages/interactive-messages/src/util.js | 41 -- .../unit/test-util.js => src/util.spec.js} | 31 +- packages/interactive-messages/src/util.ts | 52 +++ packages/interactive-messages/test/.eslintrc | 10 - packages/interactive-messages/tsconfig.json | 39 ++ packages/interactive-messages/tslint.json | 70 +++ packages/interactive-messages/types/.gitkeep | 0 .../types/lodash.isfunction.d.ts | 3 + .../types/lodash.isplainobject.d.ts | 3 + .../types/lodash.isregexp.d.ts | 3 + .../types/lodash.isstring.d.ts | 3 + .../interactive-messages/types/tsscmp.d.ts | 4 + 24 files changed, 756 insertions(+), 558 deletions(-) delete mode 100644 packages/interactive-messages/.babelrc delete mode 100644 packages/interactive-messages/.eslintignore create mode 100644 packages/interactive-messages/.mocharc.json create mode 100644 packages/interactive-messages/.nycrc.json delete mode 100644 packages/interactive-messages/src/.eslintrc rename packages/interactive-messages/{test/unit/test-adapter.js => src/adapter.spec.js} (84%) rename packages/interactive-messages/src/{adapter.js => adapter.ts} (54%) create mode 100644 packages/interactive-messages/src/errors.ts rename packages/interactive-messages/{test/unit/test-http-handler.js => src/http-handler.spec.js} (68%) rename packages/interactive-messages/src/{http-handler.js => http-handler.ts} (50%) rename packages/interactive-messages/src/{index.js => index.ts} (58%) delete mode 100644 packages/interactive-messages/src/util.js rename packages/interactive-messages/{test/unit/test-util.js => src/util.spec.js} (63%) create mode 100644 packages/interactive-messages/src/util.ts delete mode 100644 packages/interactive-messages/test/.eslintrc create mode 100644 packages/interactive-messages/tsconfig.json create mode 100644 packages/interactive-messages/tslint.json create mode 100644 packages/interactive-messages/types/.gitkeep create mode 100644 packages/interactive-messages/types/lodash.isfunction.d.ts create mode 100644 packages/interactive-messages/types/lodash.isplainobject.d.ts create mode 100644 packages/interactive-messages/types/lodash.isregexp.d.ts create mode 100644 packages/interactive-messages/types/lodash.isstring.d.ts create mode 100644 packages/interactive-messages/types/tsscmp.d.ts diff --git a/packages/interactive-messages/.babelrc b/packages/interactive-messages/.babelrc deleted file mode 100644 index 4f4b3f614..000000000 --- a/packages/interactive-messages/.babelrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "presets": [ - ["env", { - "targets": { - "node": 4 - } - }] - ], - "plugins": [ - "dynamic-import-node" - ] -} diff --git a/packages/interactive-messages/.eslintignore b/packages/interactive-messages/.eslintignore deleted file mode 100644 index f7ece9f3e..000000000 --- a/packages/interactive-messages/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -dist/**/*.js diff --git a/packages/interactive-messages/.mocharc.json b/packages/interactive-messages/.mocharc.json new file mode 100644 index 000000000..6dbaa512b --- /dev/null +++ b/packages/interactive-messages/.mocharc.json @@ -0,0 +1,3 @@ +{ + "require": ["ts-node/register", "source-map-support/register"] +} diff --git a/packages/interactive-messages/.nycrc.json b/packages/interactive-messages/.nycrc.json new file mode 100644 index 000000000..6c61b19dc --- /dev/null +++ b/packages/interactive-messages/.nycrc.json @@ -0,0 +1,14 @@ +{ + "include": [ + "src/**/*.ts" + ], + "exclude": [ + "**/*.spec.js" + ], + "reporter": ["lcov"], + "extension": [ + ".ts" + ], + "all": false, + "cache": true +} diff --git a/packages/interactive-messages/package.json b/packages/interactive-messages/package.json index 79029fef1..66f7b6dd1 100644 --- a/packages/interactive-messages/package.json +++ b/packages/interactive-messages/package.json @@ -40,10 +40,11 @@ }, "scripts": { "prepare": "npm run build", - "build": "babel src -d dist --source-maps both", - "lint": "eslint src test", - "test": "npm run build && nyc --reporter=html mocha test/**/*.js", - "coverage": "nyc report --reporter=text-lcov > coverage.lcov && codecov -F interactivemessages" + "build": "npm run build:clean && tsc", + "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", + "lint": "tslint --project .", + "test": "nyc mocha --config .mocharc.json src/*.spec.js", + "coverage": "codecov -F interactivemessages --root=$PWD" }, "dependencies": { "axios": "^0.18.0", @@ -56,23 +57,24 @@ "tsscmp": "^1.0.6" }, "devDependencies": { - "babel-cli": "^6.26.0", - "babel-eslint": "^8.2.2", - "babel-plugin-dynamic-import-node": "^1.2.0", - "babel-preset-env": "^1.6.1", + "@types/debug": "^4.1.4", + "@types/node": "^12.0.8", "body-parser": "^1.18.2", - "chai": "^4.1.2", + "chai": "^4.2.0", "codecov": "^3.0.0", "eslint": "^4.9.0", - "eslint-config-airbnb-base": "^12.1.0", - "eslint-plugin-import": "^2.7.0", "estraverse": "^4.2.0", "get-random-port": "0.0.1", "jsdoc-to-markdown": "^4.0.1", - "mocha": "^5.0.5", + "mocha": "^6.1.4", "nop": "^1.0.0", "nyc": "^11.6.0", "proxyquire": "^2.0.1", - "sinon": "^4.5.0" + "sinon": "^4.5.0", + "source-map-support": "^0.5.12", + "ts-node": "^8.2.0", + "tslint": "^5.17.0", + "tslint-config-airbnb": "^5.11.1", + "typescript": "^3.5.1" } } diff --git a/packages/interactive-messages/src/.eslintrc b/packages/interactive-messages/src/.eslintrc deleted file mode 100644 index 59dc1c130..000000000 --- a/packages/interactive-messages/src/.eslintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": [ "airbnb-base" ], - "env": { - "node": true - }, - "parser": "babel-eslint", - "parserOptions": { - "sourceType": "module" - } -} diff --git a/packages/interactive-messages/test/unit/test-adapter.js b/packages/interactive-messages/src/adapter.spec.js similarity index 84% rename from packages/interactive-messages/test/unit/test-adapter.js rename to packages/interactive-messages/src/adapter.spec.js index 4cd622814..2abde2d5b 100644 --- a/packages/interactive-messages/test/unit/test-adapter.js +++ b/packages/interactive-messages/src/adapter.spec.js @@ -1,36 +1,34 @@ -/* global Promise */ - -var http = require('http'); -var assert = require('chai').assert; -var sinon = require('sinon'); -var nop = require('nop'); -var getRandomPort = require('get-random-port'); -var systemUnderTest = require('../../dist/adapter'); -var createStreamRequest = require('../helpers').createStreamRequest; -var errorCodes = systemUnderTest.errorCodes; -var SlackMessageAdapter = systemUnderTest.default; -var delayed = require('../helpers').delayed; +require('mocha'); +const http = require('http'); +const { assert } = require('chai'); +const sinon = require('sinon'); +const nop = require('nop'); +const getRandomPort = require('get-random-port'); + +const { createStreamRequest, delayed } = require('../test/helpers'); +const { default: SlackMessageAdapter } = require('./adapter'); +const { errorCodes } = require('./index'); // fixtures -var workingSigningSecret = 'SIGNING_SECRET'; -var workingRawBody = 'payload=%7B%22type%22%3A%22interactive_message%22%7D'; +const workingSigningSecret = 'SIGNING_SECRET'; +const workingRawBody = 'payload=%7B%22type%22%3A%22interactive_message%22%7D'; // test suite describe('SlackMessageAdapter', function () { describe('constructor', function () { it('should build an instance', function () { - var adapter = new SlackMessageAdapter(workingSigningSecret); + const adapter = new SlackMessageAdapter(workingSigningSecret); assert.instanceOf(adapter, SlackMessageAdapter); assert.equal(adapter.syncResponseTimeout, 2500); }); it('should fail without a signing secret', function () { assert.throws(function () { - var adapter = new SlackMessageAdapter(); // eslint-disable-line no-unused-vars + const adapter = new SlackMessageAdapter(); // eslint-disable-line no-unused-vars }, TypeError); }); it('should allow configuring of the synchronous response timeout', function () { - var newValue = 20; - var adapter = new SlackMessageAdapter(workingSigningSecret, { + const newValue = 20; + const adapter = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: newValue }); assert.equal(adapter.syncResponseTimeout, newValue); @@ -38,11 +36,11 @@ describe('SlackMessageAdapter', function () { it('should fail when the synchronous response timeout is out of range', function () { assert.throws(function () { // eslint-disable-next-line no-unused-vars - var a = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: 0 }); + const a = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: 0 }); }, TypeError); assert.throws(function () { // eslint-disable-next-line no-unused-vars - var a = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: 3001 }); + const a = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: 3001 }); }, TypeError); }); }); @@ -61,7 +59,7 @@ describe('SlackMessageAdapter', function () { describe('#start()', function () { beforeEach(function (done) { - var self = this; + const self = this; self.adapter = new SlackMessageAdapter(workingSigningSecret); getRandomPort(function (error, port) { if (error) return done(error); @@ -73,7 +71,7 @@ describe('SlackMessageAdapter', function () { return this.adapter.stop().catch(nop); }); it('should return a Promise for a started http.Server', function () { - var self = this; + const self = this; return this.adapter.start(self.portNumber).then(function (server) { // only works in node >= 5.7.0 // assert(server.listening); @@ -84,7 +82,7 @@ describe('SlackMessageAdapter', function () { describe('#stop()', function () { beforeEach(function (done) { - var self = this; + const self = this; self.adapter = new SlackMessageAdapter(workingSigningSecret); getRandomPort(function (error, port) { if (error) return done(error); @@ -100,7 +98,7 @@ describe('SlackMessageAdapter', function () { return this.adapter.stop().catch(nop); }); it('should return a Promise and the server should be stopped', function () { - var self = this; + const self = this; return this.adapter.stop().then(function () { assert(!self.server.listening); }); @@ -120,14 +118,14 @@ describe('SlackMessageAdapter', function () { }); it('should return a function', function () { - var middleware = this.adapter.expressMiddleware(); + const middleware = this.adapter.expressMiddleware(); assert.isFunction(middleware); }); it('should error when body parser is used', function (done) { - var middleware = this.adapter.expressMiddleware(); - var req = { body: { } }; - var res = this.res; - var next = this.next; + const middleware = this.adapter.expressMiddleware(); + const req = { body: { } }; + const res = this.res; + const next = this.next; next.callsFake(function (err) { assert.equal(err.code, errorCodes.BODY_PARSER_NOT_PERMITTED); done(); @@ -135,12 +133,12 @@ describe('SlackMessageAdapter', function () { middleware(req, res, next); }); it('should verify correctly signed request bodies', function (done) { - var ts = Math.floor(Date.now() / 1000); - var adapter = this.adapter; - var middleware = adapter.expressMiddleware(); - var dispatch = this.dispatch; - var res = this.res; - var next = this.next; + const ts = Math.floor(Date.now() / 1000); + const adapter = this.adapter; + const middleware = adapter.expressMiddleware(); + const dispatch = this.dispatch; + const res = this.res; + const next = this.next; adapter.dispatch = dispatch; // Create streamed request const req = createStreamRequest(workingSigningSecret, ts, workingRawBody); @@ -160,7 +158,7 @@ describe('SlackMessageAdapter', function () { this.adapter = new SlackMessageAdapter(workingSigningSecret); }); it('should return a function', function () { - var middleware = this.adapter.requestListener(); + const middleware = this.adapter.requestListener(); assert.isFunction(middleware); }); }); @@ -175,7 +173,7 @@ describe('SlackMessageAdapter', function () { * @param {Object} [constraints] expected constraints for which handler should be registered */ function assertHandlerRegistered(adapter, handler, constraints) { - var callbackEntry; + let callbackEntry; assert.isNotEmpty(adapter.callbacks); callbackEntry = adapter.callbacks.find(function (aCallbackEntry) { @@ -210,8 +208,8 @@ describe('SlackMessageAdapter', function () { assertHandlerRegistered(this.adapter, this.handler); }); it('invalid callback_id types throw on registration', function () { - var handler = this.handler; - var adapter = this.adapter; + const handler = this.handler; + const adapter = this.adapter; assert.throws(function () { adapter[methodName](5, handler); }, TypeError); @@ -229,7 +227,7 @@ describe('SlackMessageAdapter', function () { }, TypeError); }); it('non-function callbacks throw on registration', function () { - var adapter = this.adapter; + const adapter = this.adapter; assert.throws(function () { adapter[methodName]('my_callback', 5); }, TypeError); @@ -255,9 +253,9 @@ describe('SlackMessageAdapter', function () { this.actionHandler = function () { }; }); it('should register with valid type constraints successfully', function () { - var adapter = this.adapter; - var actionHandler = this.actionHandler; - var constraintsSet = [ + const adapter = this.adapter; + const actionHandler = this.actionHandler; + const constraintsSet = [ { type: 'button' }, { type: 'select' }, { type: 'dialog_submission' } @@ -269,18 +267,18 @@ describe('SlackMessageAdapter', function () { }); }); it('should register with unfurl constraint successfully', function () { - var constraints = { unfurl: true }; + const constraints = { unfurl: true }; this.adapter.action(constraints, this.actionHandler); assertHandlerRegistered(this.adapter, this.actionHandler, constraints); }); it('should register with blockId constraints successfully', function () { - var constraints = { blockId: 'my_block' }; + const constraints = { blockId: 'my_block' }; this.adapter.action(constraints, this.actionHandler); assertHandlerRegistered(this.adapter, this.actionHandler, constraints); }); it('invalid block_id types throw on registration', function () { - var handler = this.handler; - var adapter = this.adapter; + const handler = this.handler; + const adapter = this.adapter; assert.throws(function () { adapter.action({ blockId: 5 }, handler); }, TypeError); @@ -298,13 +296,13 @@ describe('SlackMessageAdapter', function () { }, TypeError); }); it('should register with actionId constraints successfully', function () { - var constraints = { actionId: 'my_action' }; + const constraints = { actionId: 'my_action' }; this.adapter.action(constraints, this.actionHandler); assertHandlerRegistered(this.adapter, this.actionHandler, constraints); }); it('invalid action_id types throw on registration', function () { - var handler = this.handler; - var adapter = this.adapter; + const handler = this.handler; + const adapter = this.adapter; assert.throws(function () { adapter.action({ actionId: 5 }, handler); }, TypeError); @@ -322,20 +320,20 @@ describe('SlackMessageAdapter', function () { }, TypeError); }); it('should register with compound block constraints successfully', function () { - var constraints = { blockId: 'my_block', actionId: 'wham' }; + const constraints = { blockId: 'my_block', actionId: 'wham' }; this.adapter.action(constraints, this.actionHandler); assertHandlerRegistered(this.adapter, this.actionHandler, constraints); }); it('should register with valid compound constraints successfully', function () { - var constraints = { callbackId: 'my_callback', type: 'button' }; + const constraints = { callbackId: 'my_callback', type: 'button' }; this.adapter.action(constraints, this.actionHandler); assertHandlerRegistered(this.adapter, this.actionHandler, constraints); }); it('should throw when registering with invalid compound constraints', function () { - var adapter = this.adapter; - var actionHandler = this.actionHandler; + const adapter = this.adapter; + const actionHandler = this.actionHandler; // number isn't valid callbackId, all types are valid - var constraints = { callbackId: 111, type: 'button' }; + const constraints = { callbackId: 111, type: 'button' }; assert.throws(function () { adapter.action(constraints, actionHandler); }, TypeError); @@ -361,9 +359,9 @@ describe('SlackMessageAdapter', function () { this.optionsHandler = function () { }; }); it('should register with valid from constraints successfully', function () { - var adapter = this.adapter; - var optionsHandler = this.optionsHandler; - var constraintsSet = [ + const adapter = this.adapter; + const optionsHandler = this.optionsHandler; + const constraintsSet = [ { within: 'interactive_message' }, { within: 'dialog' } ]; @@ -374,22 +372,22 @@ describe('SlackMessageAdapter', function () { }); }); it('should throw when registering with invalid within constraints', function () { - var adapter = this.adapter; - var optionsHandler = this.optionsHandler; - var constraints = { within: 'not_a_real_options_source' }; + const adapter = this.adapter; + const optionsHandler = this.optionsHandler; + const constraints = { within: 'not_a_real_options_source' }; assert.throws(function () { adapter.options(constraints, optionsHandler); }, TypeError); }); it('should register with valid compound constraints successfully', function () { - var constraints = { callbackId: 'my_callback', within: 'dialog' }; + const constraints = { callbackId: 'my_callback', within: 'dialog' }; this.adapter.options(constraints, this.optionsHandler); assertHandlerRegistered(this.adapter, this.optionsHandler, constraints); }); it('should throw when registering with invalid compound constraints', function () { - var adapter = this.adapter; - var optionsHandler = this.optionsHandler; - var constraints = { callbackId: /\w+_callback/, within: 'not_a_real_options_source' }; + const adapter = this.adapter; + const optionsHandler = this.optionsHandler; + const constraints = { callbackId: /\w+_callback/, within: 'not_a_real_options_source' }; assert.throws(function () { adapter.options(constraints, optionsHandler); }, TypeError); @@ -430,9 +428,9 @@ describe('SlackMessageAdapter', function () { * @param {...Object|string} messages expected messages in request body */ function assertPostRequestMadeWithMessages(adapter, requestUrl) { - var messages = [].slice.call(arguments, 2); - var messagePromiseEntries = messages.map(function () { - var entry = {}; + const messages = [].slice.call(arguments, 2); + const messagePromiseEntries = messages.map(function () { + const entry = {}; entry.promise = new Promise(function (resolve) { entry.resolve = resolve; }); @@ -440,7 +438,7 @@ describe('SlackMessageAdapter', function () { }); sinon.stub(adapter.axios, 'post').callsFake(function (url, body) { - var messageIndex; + let messageIndex; if (url !== requestUrl) { return; } @@ -475,9 +473,9 @@ describe('SlackMessageAdapter', function () { this.replacement = { text: 'example replacement message' }; }); it('should handle the callback returning a message with a synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var replacement = this.replacement; + let dispatchResponse; + const requestPayload = this.requestPayload; + const replacement = this.replacement; this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); assert.isFunction(respond); @@ -488,10 +486,10 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise of a message before the timeout with a ' + 'synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var replacement = this.replacement; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const replacement = this.replacement; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout); this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); @@ -503,15 +501,15 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise of a message after the timeout with an ' + 'asynchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var replacement = this.replacement; - var expectedAsyncRequest = assertPostRequestMadeWithMessages( + let dispatchResponse; + const requestPayload = this.requestPayload; + const replacement = this.replacement; + const expectedAsyncRequest = assertPostRequestMadeWithMessages( this.adapter, requestPayload.response_url, replacement ); - var timeout = this.adapter.syncResponseTimeout; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); @@ -526,9 +524,9 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise that fails after the timeout with a ' + 'sychronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function () { return delayed(timeout * 1.1, undefined, 'test error'); @@ -538,9 +536,9 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise that fails before the timeout with a ' + 'sychronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout); this.adapter.action(requestPayload.callback_id, function () { return delayed(timeout * 0.1, undefined, 'test error'); @@ -549,15 +547,15 @@ describe('SlackMessageAdapter', function () { return assertResponseStatusAndMessage(dispatchResponse, 500); }); it('should handle the callback returning nothing and using respond to send a message', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var replacement = this.replacement; - var expectedAsyncRequest = assertPostRequestMadeWithMessages( + let dispatchResponse; + const requestPayload = this.requestPayload; + const replacement = this.replacement; + const expectedAsyncRequest = assertPostRequestMadeWithMessages( this.adapter, requestPayload.response_url, replacement ); - var timeout = this.adapter.syncResponseTimeout; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { delayed(timeout * 1.1) @@ -573,17 +571,17 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise of a message after the timeout with an ' + 'asynchronous response and using respond to send another asynchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var firstReplacement = this.replacement; - var secondReplacement = Object.assign({}, firstReplacement, { text: '2nd replacement' }); - var expectedAsyncRequest = assertPostRequestMadeWithMessages( + let dispatchResponse; + const requestPayload = this.requestPayload; + const firstReplacement = this.replacement; + const secondReplacement = Object.assign({}, firstReplacement, { text: '2nd replacement' }); + const expectedAsyncRequest = assertPostRequestMadeWithMessages( this.adapter, requestPayload.response_url, firstReplacement, secondReplacement ); - var timeout = this.adapter.syncResponseTimeout; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { delayed(timeout * 1.2) @@ -600,17 +598,17 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning nothing with a synchronous response and using ' + 'respond to send multiple asynchronous responses', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var firstReplacement = this.replacement; - var secondReplacement = Object.assign({}, firstReplacement, { text: '2nd replacement' }); - var expectedAsyncRequest = assertPostRequestMadeWithMessages( + let dispatchResponse; + const requestPayload = this.requestPayload; + const firstReplacement = this.replacement; + const secondReplacement = Object.assign({}, firstReplacement, { text: '2nd replacement' }); + const expectedAsyncRequest = assertPostRequestMadeWithMessages( this.adapter, requestPayload.response_url, firstReplacement, secondReplacement ); - var timeout = this.adapter.syncResponseTimeout; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { delayed(timeout * 1.1) @@ -637,10 +635,10 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise of a message after the timeout with a ' + 'synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var replacement = this.replacement; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const replacement = this.replacement; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); @@ -652,9 +650,9 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning a promise that fails after the timeout with a ' + 'sychronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function () { return delayed(timeout * 1.1, undefined, 'test error'); @@ -686,9 +684,9 @@ describe('SlackMessageAdapter', function () { this.followUp = { text: 'thanks for submitting your email address' }; }); it('should handle the callback returning a message with a synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var submissionResponse = this.submissionResponse; + let dispatchResponse; + const requestPayload = this.requestPayload; + const submissionResponse = this.submissionResponse; this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); assert.isFunction(respond); @@ -700,10 +698,10 @@ describe('SlackMessageAdapter', function () { it('should handle the callback returning a promise of a message before the timeout with a ' + 'synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var submissionResponse = this.submissionResponse; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const submissionResponse = this.submissionResponse; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout); this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); @@ -716,10 +714,10 @@ describe('SlackMessageAdapter', function () { it('should handle the callback returning a promise of a message after the timeout with a ' + 'synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var submissionResponse = this.submissionResponse; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const submissionResponse = this.submissionResponse; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); @@ -731,8 +729,8 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning nothing with a synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; + let dispatchResponse; + const requestPayload = this.requestPayload; this.adapter.action(requestPayload.callback_id, function (payload, respond) { assert.deepEqual(payload, requestPayload); assert.isFunction(respond); @@ -742,15 +740,15 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback using respond to send a follow up message', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var followUp = this.followUp; - var expectedAsyncRequest = assertPostRequestMadeWithMessages( + let dispatchResponse; + const requestPayload = this.requestPayload; + const followUp = this.followUp; + const expectedAsyncRequest = assertPostRequestMadeWithMessages( this.adapter, requestPayload.response_url, followUp ); - var timeout = this.adapter.syncResponseTimeout; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.action(requestPayload.callback_id, function (payload, respond) { delayed(timeout * 1.1) @@ -787,9 +785,9 @@ describe('SlackMessageAdapter', function () { // NOTE: if the response options or options_groups contain the property "label", we can // change them to "text" it('should handle the callback returning options with a synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var optionsResponse = this.optionsResponse; + let dispatchResponse; + const requestPayload = this.requestPayload; + const optionsResponse = this.optionsResponse; this.adapter.options(requestPayload.callback_id, function (payload, secondArg) { assert.deepEqual(payload, requestPayload); assert.isUndefined(secondArg); @@ -801,10 +799,10 @@ describe('SlackMessageAdapter', function () { it('should handle the callback returning a promise of options before the timeout with a ' + 'synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var optionsResponse = this.optionsResponse; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const optionsResponse = this.optionsResponse; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout); this.adapter.options(requestPayload.callback_id, function (payload, secondArg) { assert.deepEqual(payload, requestPayload); @@ -817,10 +815,10 @@ describe('SlackMessageAdapter', function () { it('should handle the callback returning a promise of options after the timeout with a ' + 'synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; - var optionsResponse = this.optionsResponse; - var timeout = this.adapter.syncResponseTimeout; + let dispatchResponse; + const requestPayload = this.requestPayload; + const optionsResponse = this.optionsResponse; + const timeout = this.adapter.syncResponseTimeout; this.timeout(timeout * 1.5); this.adapter.options(requestPayload.callback_id, function (payload, secondArg) { assert.deepEqual(payload, requestPayload); @@ -832,8 +830,8 @@ describe('SlackMessageAdapter', function () { }); it('should handle the callback returning nothing with a synchronous response', function () { - var dispatchResponse; - var requestPayload = this.requestPayload; + let dispatchResponse; + const requestPayload = this.requestPayload; this.adapter.options(requestPayload.callback_id, function (payload, secondArg) { assert.deepEqual(payload, requestPayload); assert.isUndefined(secondArg); @@ -913,7 +911,7 @@ describe('SlackMessageAdapter', function () { this.callback = sinon.spy(); }); it('should return undefined when there are no callbacks registered', function () { - var response = this.adapter.dispatch({}); + const response = this.adapter.dispatch({}); assert.isUndefined(response); }); @@ -923,7 +921,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a string mismatch', function () { - var response; + let response; this.adapter.action('b', this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -931,7 +929,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a RegExp mismatch', function () { - var response; + let response; this.adapter.action(/b/, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -948,7 +946,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a string mismatch', function () { - var response; + let response; this.adapter.action({ blockId: 'a' }, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -956,7 +954,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a RegExp mismatch', function () { - var response; + let response; this.adapter.action({ blockId: /a/ }, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -976,7 +974,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a string mismatch with options', function () { - var response; + let response; this.adapter.options({ blockId: 'a' }, this.callback); response = this.adapter.dispatch(this.optionsFromBlockMessagePayload); assert(this.callback.notCalled); @@ -984,7 +982,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a RegExp mismatch with options', function () { - var response; + let response; this.adapter.options({ blockId: /a/ }, this.callback); response = this.adapter.dispatch(this.optionsFromBlockMessagePayload); assert(this.callback.notCalled); @@ -1010,7 +1008,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a string mismatch', function () { - var response; + let response; this.adapter.action({ actionId: 'b' }, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -1018,7 +1016,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a RegExp mismatch', function () { - var response; + let response; this.adapter.action({ actionId: /b/ }, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -1038,7 +1036,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a string mismatch with options', function () { - var response; + let response; this.adapter.options({ actionId: 'b' }, this.callback); response = this.adapter.dispatch(this.optionsFromBlockMessagePayload); assert(this.callback.notCalled); @@ -1046,7 +1044,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined with a RegExp mismatch with options', function () { - var response; + let response; this.adapter.options({ actionId: /b/ }, this.callback); response = this.adapter.dispatch(this.optionsFromBlockMessagePayload); assert(this.callback.notCalled); @@ -1072,7 +1070,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined when type is present in constraints and it mismatches', function () { - var response; + let response; this.adapter.action({ type: 'select' }, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -1099,7 +1097,7 @@ describe('SlackMessageAdapter', function () { }); it('should return undefined when unfurl is present in constraints and it mismatches', function () { - var response; + let response; this.adapter.action({ unfurl: false }, this.callback); response = this.adapter.dispatch(this.payload); assert(this.callback.notCalled); @@ -1117,7 +1115,7 @@ describe('SlackMessageAdapter', function () { describe('within based matching (options request only)', function () { it('should return undefined when within is present in constraints and it mismatches', function () { - var response; + let response; this.adapter.options({ within: 'dialog' }, this.callback); response = this.adapter.dispatch(this.optionsFromInteractiveMessagePayload); assert(this.callback.notCalled); @@ -1167,10 +1165,10 @@ describe('SlackMessageAdapter', function () { }); it('should only match the right handler for payload when both have the same callback_id', function () { // the following payloads have the same callback_id - var actionPayload = this.buttonPayload; - var optionsPayload = this.optionsFromDialogPayload; - var actionCallback = sinon.spy(); - var optionsCallback = sinon.spy(); + const actionPayload = this.buttonPayload; + const optionsPayload = this.optionsFromDialogPayload; + const actionCallback = sinon.spy(); + const optionsCallback = sinon.spy(); this.adapter.action(actionPayload.callback_id, actionCallback); this.adapter.options(actionPayload.callback_id, optionsCallback); @@ -1189,7 +1187,7 @@ describe('SlackMessageAdapter', function () { describe('callback error handling', function () { it('should respond with an error when the registered callback throws', function () { - var response; + let response; this.adapter.action('a', function () { throw new Error('test error'); }); diff --git a/packages/interactive-messages/src/adapter.js b/packages/interactive-messages/src/adapter.ts similarity index 54% rename from packages/interactive-messages/src/adapter.js rename to packages/interactive-messages/src/adapter.ts index 25a1986ed..d3d51c50e 100644 --- a/packages/interactive-messages/src/adapter.js +++ b/packages/interactive-messages/src/adapter.ts @@ -1,33 +1,25 @@ -/** - * @module adapter - */ - +/* tslint:disable import-name */ import http from 'http'; -import axios from 'axios'; +import axios, { AxiosInstance } from 'axios'; import isString from 'lodash.isstring'; import isPlainObject from 'lodash.isplainobject'; import isRegExp from 'lodash.isregexp'; import isFunction from 'lodash.isfunction'; import debugFactory from 'debug'; -import { createHTTPHandler } from './http-handler'; -import { packageIdentifier, promiseTimeout, errorCodes as utilErrorCodes } from './util'; +import { ErrorCode, errorWithCode, CodedError } from './errors'; +import { createHTTPHandler, HTTPHandler } from './http-handler'; +import { packageIdentifier, promiseTimeout } from './util'; const debug = debugFactory('@slack/interactive-messages:adapter'); -export const errorCodes = { - BODY_PARSER_NOT_PERMITTED: 'SLACKADAPTER_BODY_PARSER_NOT_PERMITTED_FAILURE', -}; - /** * Transforms various forms of matching constraints to a single standard object shape - * @param {string|RegExp|Object} matchingConstraints - the various forms of matching constraints - * accepted - * @returns {Object} - an object where each matching constraint is a property - * @private + * @param matchingConstraints - the various forms of matching constraints accepted + * @returns an object where each matching constraint is a property */ -function formatMatchingConstraints(matchingConstraints) { - let ret = {}; - if (typeof matchingConstraints === 'undefined' || matchingConstraints === null) { +function formatMatchingConstraints(matchingConstraints: string | RegExp | object): MatchingConstraints { + let ret: any = {}; + if (matchingConstraints === undefined || matchingConstraints === null) { throw new TypeError('Constraints cannot be undefined or null'); } if (!isPlainObject(matchingConstraints)) { @@ -40,23 +32,21 @@ function formatMatchingConstraints(matchingConstraints) { /** * Validates general properties of a matching constraints object - * @param {Object} matchingConstraints - object describing the constraints on a callback - * @returns {Error|false} - a false value represents successful validation, otherwise an error to - * describe why validation failed. - * @private + * @param matchingConstraints - object describing the constraints on a callback + * @returns `false` represents successful validation, an error represents failure and describes why validation failed. */ -function validateConstraints(matchingConstraints) { - if (matchingConstraints.callbackId && +function validateConstraints(matchingConstraints: CallbackConstraints): Error | false { + if (matchingConstraints.callbackId !== undefined && !(isString(matchingConstraints.callbackId) || isRegExp(matchingConstraints.callbackId))) { return new TypeError('Callback ID must be a string or RegExp'); } - if (matchingConstraints.blockId && + if (matchingConstraints.blockId !== undefined && !(isString(matchingConstraints.blockId) || isRegExp(matchingConstraints.blockId))) { return new TypeError('Block ID must be a string or RegExp'); } - if (matchingConstraints.actionId && + if (matchingConstraints.actionId !== undefined && !(isString(matchingConstraints.actionId) || isRegExp(matchingConstraints.actionId))) { return new TypeError('Action ID must be a string or RegExp'); } @@ -66,13 +56,11 @@ function validateConstraints(matchingConstraints) { /** * Validates properties of a matching constraints object specific to registering an options request - * @param {Object} matchingConstraints - object describing the constraints on a callback - * @returns {Error|false} - a false value represents successful validation, otherwise an error to - * describe why validation failed. - * @private + * @param matchingConstraints - object describing the constraints on a callback + * @returns `false` represents successful validation, an error represents failure and describes why validation failed. */ -function validateOptionsConstraints(optionsConstraints) { - if (optionsConstraints.within && +function validateOptionsConstraints(optionsConstraints: any): Error | false { + if (optionsConstraints.within !== undefined && !(optionsConstraints.within === 'interactive_message' || optionsConstraints.within === 'block_actions' || optionsConstraints.within === 'dialog') @@ -89,22 +77,41 @@ function validateOptionsConstraints(optionsConstraints) { * @typicalname slackInteractions */ export class SlackMessageAdapter { + /** + * Slack app signing secret used to authenticate request + */ + public signingSecret: string; + + /** + * The number of milliseconds to wait before flushing a syncrhonous response to an incoming request and falling back + * to an asynchronous response. + */ + public syncResponseTimeout: number; + + /** + * Whether or not promises that resolve after the syncResponseTimeout can fallback to a request for the response_url. + * This only works in cases where the semantic meaning of the response and the response_url are the same. + */ + public lateResponseFallbackEnabled: boolean; + + private callbacks: [MatchingConstraints, ActionHandler][]; + private axios: AxiosInstance; + private server?: http.Server; + /** * Create a message adapter. * - * @param {string} signingSecret - Slack app signing secret used to authenticate request - * @param {Object} [options] - * @param {number} [options.syncResponseTimeout=2500] - number of milliseconds to wait before - * flushing a syncrhonous response to an incoming request and falling back to an asynchronous - * response. - * @param {boolean} [options.lateResponseFallbackEnabled=true] - whether or not promises that - * resolve after the syncResponseTimeout can fallback to a request for the response_url. this only - * works in cases where the semantic meaning of the response and the response_url are the same. + * @param signingSecret - Slack app signing secret used to authenticate request + * @param options.syncResponseTimeout - number of milliseconds to wait before flushing a syncrhonous response to an + * incoming request and falling back to an asynchronous response. + * @param options.lateResponseFallbackEnabled - whether or not promises that resolve after the syncResponseTimeout can + * fallback to a request for the response_url. this only works in cases where the semantic meaning of the response + * and the response_url are the same. */ - constructor(signingSecret, { + constructor(signingSecret: string, { syncResponseTimeout = 2500, lateResponseFallbackEnabled = true, - } = {}) { + }: MessageAdapterOptions = {}) { if (!isString(signingSecret)) { throw new TypeError('SlackMessageAdapter needs a signing secret'); } @@ -129,16 +136,16 @@ export class SlackMessageAdapter { /* Interface for using the built-in server */ /** - * Create a server that dispatches Slack's interactive message actions and menu requests to this - * message adapter instance. Use this method if your application will handle starting the server. + * Create a server that dispatches Slack's interactive message actions and menu requests to this message adapter + * instance. Use this method if your application will handle starting the server. * - * @param {string} [path=/slack/actions] - The path portion of the URL where the server will - * listen for requests from Slack's interactive messages. - * @returns {Promise} - A promise that resolves to an instance of http.Server and - * will dispatch interactive message actions and options requests to this message adapter - * instance. https://nodejs.org/dist/latest/docs/api/http.html#http_class_http_server + * @param path - The path portion of the URL where the server will listen for requests from Slack's interactive + * messages. + * @returns A promise that resolves to an instance of http.Server and will dispatch interactive message actions and + * options requests to this message adapter instance. See + * https://nodejs.org/dist/latest/docs/api/http.html#http_class_http_server */ - createServer(path = '/slack/actions') { + public createServer(path: string = '/slack/actions'): Promise { // TODO: more options (like https) return Promise.resolve().then(() => { debug('server created - path: %s', path); @@ -148,13 +155,12 @@ export class SlackMessageAdapter { } /** - * Start a built-in server that dispatches Slack's interactive message actions and menu requests - * to this message adapter interface. + * Start a built-in server that dispatches Slack's interactive message actions and menu requests to this message + * adapter interface. * - * @param {number} port - * @returns {Promise} - A promise that resolves once the server is ready + * @returns A promise that resolves once the server is ready */ - start(port) { + public start(port: number): Promise { return this.createServer() .then(server => new Promise((resolve, reject) => { this.server = server; @@ -167,14 +173,14 @@ export class SlackMessageAdapter { /** * Stop the previously started built-in server. * - * @returns {Promise} - A promise that resolves once the server is cleaned up. + * @returns A promise that resolves once the server is cleaned up. */ - stop() { + public stop(): Promise { return new Promise((resolve, reject) => { - if (this.server) { + if (this.server !== undefined) { this.server.close((error) => { delete this.server; - if (error) { + if (error !== undefined) { reject(error); } else { resolve(); @@ -189,19 +195,24 @@ export class SlackMessageAdapter { /* Interface for bringing your own server */ /** - * Create a middleware function that can be used to integrate with the `express` web framework - * in order for incoming requests to be dispatched to this message adapter instance. + * Create a middleware function that can be used to integrate with the `express` web framework in order for incoming + * requests to be dispatched to this message adapter instance. * - * @returns {ExpressMiddlewareFunc} - A middleware function http://expressjs.com/en/guide/using-middleware.html + * @returns A middleware function (see http://expressjs.com/en/guide/using-middleware.html) */ - expressMiddleware() { + public expressMiddleware(): ( + req: http.IncomingMessage & { body?: string }, + res: http.ServerResponse, + next: Function, + ) => void { const requestListener = this.requestListener(); return (req, res, next) => { // If parser is being used, we can't verify request signature - if (req.body) { - const error = new Error('Parsing request body prohibits request signature verification'); - error.code = errorCodes.BODY_PARSER_NOT_PERMITTED; - next(error); + if (req.body !== undefined) { + next(errorWithCode( + new Error('Parsing request body prohibits request signature verification'), + ErrorCode.BodyParserNotPermitted, + )); return; } requestListener(req, res); @@ -209,23 +220,20 @@ export class SlackMessageAdapter { } /** - * Create a request listener function that handles HTTP requests, verifies requests - * and dispatches responses - * - * @returns {slackRequestListener} + * Create a request listener function that handles HTTP requests, verifies requests and dispatches responses */ - requestListener() { + public requestListener(): HTTPHandler { return createHTTPHandler(this); } /* Interface for adding handlers */ - /* eslint-disable max-len */ + /* tslint:disable max-line-length */ /** * Add a handler for an interactive message action. * - * Usually there's no need to be concerned with _how_ a message is sent to Slack, but the - * following table describes it fully. + * Usually there's no need to be concerned with _how_ a message is sent to Slack, but the following table describes it + * fully. * * **Action**|**Return `object`**|**Return `Promise`**|**Return `undefined`**|**Call `respond(message)`**|**Notes** * :-----:|:-----:|:-----:|:-----:|:-----:|:-----: @@ -234,23 +242,16 @@ export class SlackMessageAdapter { * **Message Action** | Message in response | When resolved before `syncResposeTimeout` or `lateResponseFallbackEnabled: false`, message in response
When resolved after `syncResponseTimeout` and `lateResponseFallbackEnabled: true`, message in request to `response_url` | Empty response | Message in request to `response_url` | * **Dialog Submission**| Error list in response | Error list in response | Empty response | Message in request to `response_url` | Returning a Promise that takes longer than 3 seconds to resolve can result in the user seeing an error. Warning logged if a promise isn't completed before `syncResponseTimeout`. * - * @param {Object|string|RegExp} matchingConstraints - the callback ID (as a string or RegExp) or - * an object describing the constraints to match actions for the handler. - * @param {string|RegExp} [matchingConstraints.callbackId] - a string or RegExp to match against - * the `callback_id` - * @param {string|RegExp} [matchingConstraints.blockId] - a string or RegExp to match against - * the `block_id` - * @param {string|RegExp} [matchingConstraints.actionId] - a string or RegExp to match against - * the `action_id` - * @param {string} [matchingConstraints.type] - valid types include all - * [actions block elements](https://api.slack.com/reference/messaging/interactive-components), - * `select` only for menu selections, or `dialog_submission` only for dialog submissions - * @param {boolean} [matchingConstraints.unfurl] - when `true` only match actions from an unfurl - * @param {module:adapter~SlackMessageAdapter~ActionHandler} callback - the function to run when - * an action is matched - * @returns {module:adapter~SlackMessageAdapter} - this instance (for chaining) + * @param matchingConstraints - the callback ID (as a string or RegExp) or an object describing the constraints to + * match actions for the handler. + * @param callback - the function to run when an action is matched + * @returns this instance (for chaining) */ - action(matchingConstraints, callback) { + /* tslint:enable max-line-length */ + public action( + matchingConstraints: string | RegExp | CallbackConstraints, + callback: ActionHandler, + ): SlackMessageAdapter { /* eslint-enable max-len */ const actionConstraints = formatMatchingConstraints(matchingConstraints); actionConstraints.handlerType = 'action'; @@ -264,7 +265,7 @@ export class SlackMessageAdapter { return this.registerCallback(actionConstraints, callback); } - /* eslint-disable max-len */ + /* tslint:disable max-line-length */ /** * Add a handler for an options request * @@ -275,22 +276,16 @@ export class SlackMessageAdapter { * :-----:|:-----:|:-----:|:-----:|:-----: * **Options Request**| Options in response | Options in response | Empty response | Returning a Promise that takes longer than 3 seconds to resolve can result in the user seeing an error. If the request is from within a dialog, the `text` field is called `label`. * - * @param {object} matchingConstraints - the callback ID (as a string or RegExp) or - * an object describing the constraints to select options requests for the handler. - * @param {string|RegExp} [matchingConstraints.callbackId] - a string or RegExp to match against - * the `callback_id` - * @param {string|RegExp} [matchingConstraints.blockId] - a string or RegExp to match against - * the `block_id` - * @param {string|RegExp} [matchingConstraints.actionId] - a string or RegExp to match against - * the `action_id` - * @param {string} [matchingConstraints.within] - `block_actions` only for external select - * in actions block, `interactive_message` only for menus in an interactive message, or - * `dialog` only for menus in a dialog - * @param {module:adapter~SlackMessageAdapter~OptionsHandler} callback - the function to run when - * an options request is matched - * @returns {module:adapter~SlackMessageAdapter} - this instance (for chaining) + * @param matchingConstraints - the callback ID (as a string or RegExp) or an object describing the constraints to + * select options requests for the handler. + * @param callback - the function to run when an options request is matched + * @returns this instance (for chaining) */ - options(matchingConstraints, callback) { + /* tslint:enable max-line-length */ + public options( + matchingConstraints: string | RegExp | CallbackConstraints, + callback: OptionsHandler, + ): SlackMessageAdapter { /* eslint-enable max-len */ const optionsConstraints = formatMatchingConstraints(matchingConstraints); optionsConstraints.handlerType = 'options'; @@ -310,16 +305,13 @@ export class SlackMessageAdapter { /** * Dispatches the contents of an HTTP request to the registered handlers. * - * @param {object} payload - * @returns {Promise<{ status: number, content: object|string|undefined }>|undefined} - A promise - * of the response information (an object with status and content that is a JSON serializable - * object or a string or undefined) for the request. An undefined return value indicates that the - * request was not matched. - * @private + * @returns A promise of the response information (an object with status and content that is a JSON serializable + * object or a string or undefined) for the request. An undefined return value indicates that the request was not + * matched. */ - dispatch(payload) { + public dispatch(payload: any): Promise<{ status: number, content?: object | string }> | undefined { const callback = this.matchCallback(payload); - if (!callback) { + if (callback === undefined) { debug('dispatch could not find a handler'); return undefined; } @@ -327,44 +319,41 @@ export class SlackMessageAdapter { const [, callbackFn] = callback; // when a response_url is present,`respond()` function created to to send a message using it - let respond; - if (payload.response_url) { - respond = (message) => { - if (typeof message.then === 'function') { - throw new TypeError('Cannot use a Promise as the parameter for respond()'); - } - debug('sending async response'); - return this.axios.post(payload.response_url, message); - }; - } + const respond: Respond | undefined = payload.response_url ? (message: object): Promise => { + if (typeof (message as any).then === 'function') { + throw new TypeError('Cannot use a Promise as the parameter for respond()'); + } + debug('sending async response'); + return this.axios.post(payload.response_url, message); + } : undefined; - let callbackResult; + let callbackResult: ReturnType; try { - callbackResult = callbackFn.call(this, payload, respond); + callbackResult = callbackFn.call(this, payload, respond as Respond); } catch (error) { debug('callback error: %o', error); return Promise.resolve({ status: 500 }); } - if (callbackResult) { + if (callbackResult !== undefined) { return promiseTimeout(this.syncResponseTimeout, callbackResult) - .then(content => ({ status: 200, content })) - .catch((error) => { - if (error.code === utilErrorCodes.PROMISE_TIMEOUT) { + .then(content => ({ content, status: 200 })) + .catch((error: CodedError) => { + if (error.code === ErrorCode.PromiseTimeout) { // warn and continue for promises that cannot be saved with a later async response. // this includes dialog submissions because the response_url doesn't have the same // semantics as the response, any request that doesn't contain a response_url, and // if this has been explicitly disabled in the configuration. - if (!this.lateResponseFallbackEnabled || !respond || payload.type === 'dialog_submission') { + if (!this.lateResponseFallbackEnabled || respond === undefined || payload.type === 'dialog_submission') { debug('WARNING: The response Promise did not resolve under the timeout.'); - return callbackResult - .then(content => ({ status: 200, content })) + return (callbackResult as Promise) + .then(content => ({ content, status: 200 })) .catch(() => ({ status: 500 })); } // save a late promise by sending an empty body in the response, and then use the // response_url to send the eventually resolved value - callbackResult.then(respond).catch((callbackError) => { + (callbackResult as Promise).then(respond).catch((callbackError) => { // when the promise is late and fails, we cannot do anything but log it debug('ERROR: Promise was late and failed. Use `.catch()` to handle errors.'); throw callbackError; @@ -383,10 +372,7 @@ export class SlackMessageAdapter { return Promise.resolve({ status: 200 }); } - /** - * @private - */ - registerCallback(constraints, callback) { + private registerCallback(constraints: MatchingConstraints, callback: ActionHandler): SlackMessageAdapter { // Validation if (!isFunction(callback)) { debug('did not register callback because its not a function'); @@ -398,17 +384,14 @@ export class SlackMessageAdapter { return this; } - /** - * @private - */ - matchCallback(payload) { + private matchCallback(payload: any): [MatchingConstraints, ActionHandler] | undefined { return this.callbacks.find(([constraints]) => { // if the callback ID constraint is specified, only continue if it matches - if (constraints.callbackId) { + if (constraints.callbackId !== undefined) { if (isString(constraints.callbackId) && payload.callback_id !== constraints.callbackId) { return false; } - if (isRegExp(constraints.callbackId) && !constraints.callbackId.test(payload.callback_id)) { + if (isRegExp(constraints.callbackId) && !(constraints.callbackId as RegExp).test(payload.callback_id)) { return false; } } @@ -425,21 +408,21 @@ export class SlackMessageAdapter { const action = payload.actions ? payload.actions[0] : {}; // if the block ID constraint is specified, only continue if it matches - if (constraints.blockId) { + if (constraints.blockId !== undefined) { if (isString(constraints.blockId) && action.block_id !== constraints.blockId) { return false; } - if (isRegExp(constraints.blockId) && !constraints.blockId.test(action.block_id)) { + if (isRegExp(constraints.blockId) && !(constraints.blockId as RegExp).test(action.block_id)) { return false; } } // if the action ID constraint is specified, only continue if it matches - if (constraints.actionId) { + if (constraints.actionId !== undefined) { if (isString(constraints.actionId) && action.action_id !== constraints.actionId) { return false; } - if (isRegExp(constraints.actionId) && !constraints.actionId.test(action.action_id)) { + if (isRegExp(constraints.actionId) && !(constraints.actionId as RegExp).test(action.action_id)) { return false; } } @@ -448,12 +431,13 @@ export class SlackMessageAdapter { // actions have a type defined at the top level, and select actions don't have a type // defined, but type can be inferred by checking if a `selected_options` property exists in // the action. + // tslint:disable-next-line const type = action.type || payload.type || (action.selected_options && 'select'); if (!type) { debug('no type found in dispatched action'); } // if the type constraint is specified, only continue if it matches - if (constraints.type && constraints.type !== type) { + if (constraints.type !== undefined && constraints.type !== type) { return false; } @@ -476,21 +460,21 @@ export class SlackMessageAdapter { } // if the block ID constraint is specified, only continue if it matches - if (constraints.blockId) { + if (constraints.blockId !== undefined) { if (isString(constraints.blockId) && payload.block_id !== constraints.blockId) { return false; } - if (isRegExp(constraints.blockId) && !constraints.blockId.test(payload.block_id)) { + if (isRegExp(constraints.blockId) && !(constraints.blockId as RegExp).test(payload.block_id)) { return false; } } // if the action ID constraint is specified, only continue if it matches - if (constraints.actionId) { + if (constraints.actionId !== undefined) { if (isString(constraints.actionId) && payload.action_id !== constraints.actionId) { return false; } - if (isRegExp(constraints.actionId) && !constraints.actionId.test(payload.action_id)) { + if (isRegExp(constraints.actionId) && !(constraints.actionId as RegExp).test(payload.action_id)) { return false; } } @@ -500,7 +484,7 @@ export class SlackMessageAdapter { // * type:interactive_message => within:interactive_message // * type:block_suggestion => within:block_actions // * type:dialog_suggestion => within:dialog - if (constraints.within) { + if (constraints.within !== undefined) { if (constraints.within === 'interactive_message' && payload.type !== 'interactive_message') { return false; } @@ -525,73 +509,99 @@ export class SlackMessageAdapter { export default SlackMessageAdapter; /** - * @external ExpressMiddlewareFunc - * @see http://expressjs.com/en/guide/using-middleware.html + * Options for constructing {@link SlackMessageAdapter}. + */ +export interface MessageAdapterOptions { + syncResponseTimeout?: number; + lateResponseFallbackEnabled?: boolean; +} + +/** + * Constraints that determine when a callback is fired. */ +interface CallbackConstraints { + /** + * A string or RegExp to match against the `callback_id` + */ + callbackId?: string | RegExp; + + /** + * A string or RegExp to match against the `block_id` + */ + blockId?: string | RegExp; + + /** + * A string or RegExp to match against the `action_id` + */ + actionId?: string | RegExp; + + /** + * valid types include all + * [actions block elements](https://api.slack.com/reference/messaging/interactive-components), + * `select` only for menu selections, or `dialog_submission` only for dialog submissions + */ + type?: string; + + /** + * When `true` only match actions from an unfurl + */ + unfurl?: boolean; +} /** - * @external NodeHttpServer - * @see https://nodejs.org/dist/latest/docs/api/http.html#http_class_http_server + * A matchable/testable set of constraints. */ +type MatchingConstraints = CallbackConstraints & { + handlerType: string; + within: 'interactive_message' | 'block_actions' | 'dialog'; +}; /** * A handler function for action requests (block actions, button presses, menu selections, * and dialog submissions). * - * @name module:adapter~SlackMessageAdapter~ActionHandler - * @function - * @param {Object} payload - an object describing the - * [block actions](https://api.slack.com/messaging/interactivity/enabling#understanding-payloads) - * [button press](https://api.slack.com/docs/message-buttons#responding_to_message_actions), - * [menu selection](https://api.slack.com/docs/message-menus#request_url_response), or - * [dialog submission](https://api.slack.com/dialogs#evaluating_submission_responses). - * @param {module:adapter~SlackMessageAdapter~ActionHandler~Respond} respond - When the action is a - * button press or menu selection, this function is used to update the message where the action - * occurred or create new messages in the same conversation. When the action is a dialog submission, - * this function is used to create new messages in the conversation where the dialog was triggered. - * @returns {Object} When the action is a button press or a menu selection, this object is a - * replacement - * [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields) - * for the message in which the action occurred. It may also be a Promise for a message, and if so - * and the Promise takes longer than the `syncResponseTimeout` to complete, the message is sent over - * the `response_url`. The message may also be a new message in the same conversation by setting - * `replace_original: false`. When the action is a dialog submission, this object is a list of - * [validation errors](https://api.slack.com/dialogs#input_validation). It may also be a Promise for - * a list of validation errors, and if so and the Promise takes longer than the - * `syncReponseTimeout` to complete, Slack will display an error to the user. If there is no return - * value, then button presses and menu selections do not update the message and dialog submissions - * will validate and dismiss. + * @param payload - an object describing the + * [block actions](https://api.slack.com/messaging/interactivity/enabling#understanding-payloads) + * [button press](https://api.slack.com/docs/message-buttons#responding_to_message_actions), + * [menu selection](https://api.slack.com/docs/message-menus#request_url_response), or + * [dialog submission](https://api.slack.com/dialogs#evaluating_submission_responses). + * @param respond - When the action is a button press or menu selection, this function is used to update the message + * where the action occurred or create new messages in the same conversation. When the action is a dialog submission, + * this function is used to create new messages in the conversation where the dialog was triggered. + * @returns When the action is a button press or a menu selection, this object is a replacement + * [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields) for the message in + * which the action occurred. It may also be a Promise for a message, and if so and the Promise takes longer than the + * `syncResponseTimeout` to complete, the message is sent over the `response_url`. The message may also be a new + * message in the same conversation by setting `replace_original: false`. When the action is a dialog submission, + * this object is a list of [validation errors](https://api.slack.com/dialogs#input_validation). It may also be a + * Promise for a list of validation errors, and if so and the Promise takes longer than the `syncReponseTimeout` to + * complete, Slack will display an error to the user. If there is no return value, then button presses and menu + * selections do not update the message and dialog submissions will validate and dismiss. */ +type ActionHandler = (payload: object, respond: Respond) => object | Promise | undefined; /** * A function used to send message updates after an action is handled. This function can be used * up to 5 times in 30 minutes. * - * @name module:adapter~SlackMessageAdapter~ActionHandler~Respond - * @function - * @param {Object} message - a - * [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields). - * Dialog submissions do not allow `resplace_original: false` on this message. - * @returns {Promise} there's no contract or interface for the resolution value, but this Promise - * will resolve when the HTTP response from the `response_url` request is complete and reject when - * there is an error. + * @param message - a [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields). + * Dialog submissions do not allow `resplace_original: false` on this message. @returnsthere's no contract or + * interface for the resolution value, but this Promise will resolve when the HTTP response from the `response_url` + * request is complete and reject when there is an error. */ +type Respond = (message: any) => Promise; /** * A handler function for menu options requests. * - * @name module:adapter~SlackMessageAdapter~OptionsHandler - * @function - * @param {Object} payload - an object describing - * [the state of the menu](https://api.slack.com/docs/message-menus#options_load_url) - * @returns {Object} an - * [options list](https://api.slack.com/docs/interactive-message-field-guide#option_fields) or - * [option groups list](https://api.slack.com/docs/interactive-message-field-guide#option_groups). - * When the menu is within an interactive message, (`within: 'interactive_message'`) the option - * keys are `text` and `value`. When the menu is within a dialog (`within: 'dialog'`) the option - * keys are `label` and `value`. When the menu is within a dialog (`within: 'block_actions'`) the - * option keys are a text block and `value`. This function may also return a Promise either of - * these values. If a Promise is returned and it does not complete within 3 seconds, Slack will - * display an error to the user. If there is no return value, then the user is shown an empty list - * of options. + * @param payload - an object describing + * [the state of the menu](https://api.slack.com/docs/message-menus#options_load_url) + * @returns an [options list](https://api.slack.com/docs/interactive-message-field-guide#option_fields) or + * [option groups list](https://api.slack.com/docs/interactive-message-field-guide#option_groups). When the menu is + * within an interactive message, (`within: 'interactive_message'`) the option keys are `text` and `value`. When the + * menu is within a dialog (`within: 'dialog'`) the option keys are `label` and `value`. When the menu is within a + * dialog (`within: 'block_actions'`) the option keys are a text block and `value`. This function may also return a + * Promise either of these values. If a Promise is returned and it does not complete within 3 seconds, Slack will + * display an error to the user. If there is no return value, then the user is shown an empty list of options. */ +type OptionsHandler = (payload: object) => object | Promise | undefined; diff --git a/packages/interactive-messages/src/errors.ts b/packages/interactive-messages/src/errors.ts new file mode 100644 index 000000000..87e1253ba --- /dev/null +++ b/packages/interactive-messages/src/errors.ts @@ -0,0 +1,30 @@ +/** + * A dictionary of codes for errors produced by this package. + */ +export enum ErrorCode { + PromiseTimeout = 'SLACKMESSAGEUTIL_PROMISE_TIMEOUT', + SignatureVerificationFailure = 'SLACKHTTPHANDLER_REQUEST_SIGNATURE_VERIFICATION_FAILURE', + RequestTimeFailure = 'SLACKHTTPHANDLER_REQUEST_TIMELIMIT_FAILURE', + BodyParserNotPermitted = 'SLACKADAPTER_BODY_PARSER_NOT_PERMITTED_FAILURE', +} + +/** + * All errors produced by this package are regular + * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error | Error} objects with + * an extra {@link CodedError.code | `error`} field. + */ +export interface CodedError extends Error { + /** + * What kind of error occurred. + */ + code: ErrorCode; +} + +/** + * Factory for producing a {@link CodedError} from a generic error. + */ +export function errorWithCode(error: Error, code: ErrorCode): CodedError { + const codedError = error as CodedError; + codedError.code = code; + return codedError; +} \ No newline at end of file diff --git a/packages/interactive-messages/test/unit/test-http-handler.js b/packages/interactive-messages/src/http-handler.spec.js similarity index 68% rename from packages/interactive-messages/test/unit/test-http-handler.js rename to packages/interactive-messages/src/http-handler.spec.js index 9f2e96f77..51e6b466e 100644 --- a/packages/interactive-messages/test/unit/test-http-handler.js +++ b/packages/interactive-messages/src/http-handler.spec.js @@ -1,15 +1,17 @@ -var assert = require('chai').assert; -var sinon = require('sinon'); -var proxyquire = require('proxyquire'); -var createRequest = require('../helpers').createRequest; -var correctRawBody = 'payload=%7B%22type%22%3A%22interactive_message%22%7D'; -var getRawBodyStub = sinon.stub(); -var systemUnderTest = proxyquire('../../dist/http-handler', { +require('mocha'); +const { assert } = require('chai'); +const sinon = require('sinon'); +const proxyquire = require('proxyquire'); + +const { createRequest } = require('../test/helpers'); + +const getRawBodyStub = sinon.stub(); +const { createHTTPHandler } = proxyquire('./http-handler', { 'raw-body': getRawBodyStub }); -var createHTTPHandler = systemUnderTest.createHTTPHandler; -// fixtures -var correctSigningSecret = 'SIGNING_SECRET'; + +const correctRawBody = 'payload=%7B%22type%22%3A%22interactive_message%22%7D'; +const correctSigningSecret = 'SIGNING_SECRET'; describe('createHTTPHandler', function () { beforeEach(function () { @@ -28,10 +30,10 @@ describe('createHTTPHandler', function () { }); it('should verify a correct signing secret', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var date = Math.floor(Date.now() / 1000); - var req = createRequest(correctSigningSecret, date, correctRawBody); + const dispatch = this.dispatch; + const res = this.res; + const date = Math.floor(Date.now() / 1000); + const req = createRequest(correctSigningSecret, date, correctRawBody); dispatch.resolves({ status: 200 }); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function () { @@ -43,9 +45,9 @@ describe('createHTTPHandler', function () { }); it('should fail request signing verification with an incorrect signing secret', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var req = createRequest('INVALID_SECRET', this.correctDate, correctRawBody); + const dispatch = this.dispatch; + const res = this.res; + const req = createRequest('INVALID_SECRET', this.correctDate, correctRawBody); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function () { assert(dispatch.notCalled); @@ -56,10 +58,10 @@ describe('createHTTPHandler', function () { }); it('should fail request signing verification with old timestamp', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var sixMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 6); - var req = createRequest(correctSigningSecret, sixMinutesAgo, correctRawBody); + const dispatch = this.dispatch; + const res = this.res; + const sixMinutesAgo = Math.floor(Date.now() / 1000) - (60 * 6); + const req = createRequest(correctSigningSecret, sixMinutesAgo, correctRawBody); dispatch.resolves({ status: 200 }); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function () { @@ -71,8 +73,8 @@ describe('createHTTPHandler', function () { }); it('should handle unexpected error', function (done) { - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); getRawBodyStub.rejects(new Error('test error')); res.end.callsFake(function (result) { assert.equal(res.statusCode, 500); @@ -83,8 +85,8 @@ describe('createHTTPHandler', function () { }); it('should provide message with unexpected errors in development', function (done) { - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); process.env.NODE_ENV = 'development'; getRawBodyStub.rejects(new Error('test error')); res.end.callsFake(function (result) { @@ -97,9 +99,9 @@ describe('createHTTPHandler', function () { }); it('should handle no callback', function (done) { - var res = this.res; - var dispatch = this.dispatch; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const res = this.res; + const dispatch = this.dispatch; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); dispatch.returns(undefined); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function () { @@ -110,9 +112,9 @@ describe('createHTTPHandler', function () { }); it('should set an identification header in its responses', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const dispatch = this.dispatch; + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); dispatch.resolves({ status: 200 }); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function () { @@ -123,10 +125,10 @@ describe('createHTTPHandler', function () { }); it('should respond to ssl check requests', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var sslRawBody = 'payload=%7B%22ssl_check%22%3A%221%22%7D'; - var req = createRequest(correctSigningSecret, this.correctDate, sslRawBody); + const dispatch = this.dispatch; + const res = this.res; + const sslRawBody = 'payload=%7B%22ssl_check%22%3A%221%22%7D'; + const req = createRequest(correctSigningSecret, this.correctDate, sslRawBody); getRawBodyStub.resolves(sslRawBody); res.end.callsFake(function () { assert(dispatch.notCalled); @@ -138,10 +140,10 @@ describe('createHTTPHandler', function () { describe('handling dispatch results', function () { it('should serialize objects in the content key as JSON', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); - var content = { + const dispatch = this.dispatch; + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const content = { abc: 'def', ghi: true, jkl: ['m', 'n', 'o'], @@ -160,9 +162,9 @@ describe('createHTTPHandler', function () { }); it('should handle an undefined content key as no body', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const dispatch = this.dispatch; + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); dispatch.resolves({ status: 500 }); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function (body) { @@ -175,10 +177,10 @@ describe('createHTTPHandler', function () { }); it('should handle a string content key as the literal body', function (done) { - var dispatch = this.dispatch; - var res = this.res; - var req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); - var content = 'hello, world'; + const dispatch = this.dispatch; + const res = this.res; + const req = createRequest(correctSigningSecret, this.correctDate, correctRawBody); + const content = 'hello, world'; dispatch.resolves({ status: 200, content: content }); getRawBodyStub.resolves(correctRawBody); res.end.callsFake(function (body) { diff --git a/packages/interactive-messages/src/http-handler.js b/packages/interactive-messages/src/http-handler.ts similarity index 50% rename from packages/interactive-messages/src/http-handler.js rename to packages/interactive-messages/src/http-handler.ts index 9a33fb022..92a66e2c1 100644 --- a/packages/interactive-messages/src/http-handler.js +++ b/packages/interactive-messages/src/http-handler.ts @@ -1,34 +1,32 @@ +/* tslint:disable import-name */ +import { IncomingMessage, ServerResponse } from 'http'; +import * as querystring from 'querystring'; import debugFactory from 'debug'; import getRawBody from 'raw-body'; -import querystring from 'querystring'; import crypto from 'crypto'; import timingSafeCompare from 'tsscmp'; +import SlackMessageAdapter from './adapter'; +import { ErrorCode, errorWithCode } from './errors'; import { packageIdentifier } from './util'; const debug = debugFactory('@slack/interactive-messages:http-handler'); -export const errorCodes = { - SIGNATURE_VERIFICATION_FAILURE: 'SLACKHTTPHANDLER_REQUEST_SIGNATURE_VERIFICATION_FAILURE', - REQUEST_TIME_FAILURE: 'SLACKHTTPHANDLER_REQUEST_TIMELIMIT_FAILURE', -}; - -export function createHTTPHandler(adapter) { +export function createHTTPHandler(adapter: SlackMessageAdapter): HTTPHandler { const poweredBy = packageIdentifier(); /** * Handles sending responses * - * @param {Object} res - Response object - * @returns {Function} Returns a function used to send response + * @param res - Response object + * @returns Returns a function used to send response */ - function sendResponse(res) { - return function _sendResponse(dispatchResult) { - const { status, content } = dispatchResult; + function sendResponse(res: ServerResponse): ResponseHandler { + return ({ status, content }) => { res.statusCode = status; res.setHeader('X-Slack-Powered-By', poweredBy); if (typeof content === 'string') { res.end(content); - } else if (content) { + } else if (content !== undefined) { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(content)); } else { @@ -40,13 +38,13 @@ export function createHTTPHandler(adapter) { /** * Parses raw bodies of requests * - * @param {string} body - Raw body of request - * @returns {Object} Parsed body of the request + * @param body - Raw body of request + * @returns Parsed body of the request */ - function parseBody(body) { + function parseBody(body: string): object { const parsedBody = querystring.parse(body); - if (parsedBody.payload) { - return JSON.parse(parsedBody.payload); + if (parsedBody.payload !== undefined) { + return JSON.parse(parsedBody.payload as string); } return parsedBody; @@ -55,16 +53,20 @@ export function createHTTPHandler(adapter) { /** * Method to verify signature of requests * - * @param {string} signingSecret - Signing secret used to verify request signature - * @param {Object} requestHeaders - Request headers - * @param {string} body - Raw body string - * @returns {boolean} Indicates if request is verified + * @param signingSecret - Signing secret used to verify request signature + * @param requestHeaders - Request headers + * @param body - Raw body string + * @returns Indicates if request is verified */ - function verifyRequestSignature(signingSecret, requestHeaders, body) { + function verifyRequestSignature( + signingSecret: string, + requestHeaders: Record, + body: string, + ): boolean { // Request signature const signature = requestHeaders['x-slack-signature']; // Request timestamp - const ts = requestHeaders['x-slack-request-timestamp']; + const ts = parseInt(requestHeaders['x-slack-request-timestamp'], 10); // Divide current date to match Slack ts format // Subtract 5 minutes from current time @@ -72,9 +74,7 @@ export function createHTTPHandler(adapter) { if (ts < fiveMinutesAgo) { debug('request is older than 5 minutes'); - const error = new Error('Slack request signing verification failed'); - error.code = errorCodes.REQUEST_TIME_FAILURE; - throw error; + throw errorWithCode(new Error('Slack request signing verification failed'), ErrorCode.RequestTimeFailure); } const hmac = crypto.createHmac('sha256', signingSecret); @@ -83,9 +83,10 @@ export function createHTTPHandler(adapter) { if (!timingSafeCompare(hash, hmac.digest('hex'))) { debug('request signature is not valid'); - const error = new Error('Slack request signing verification failed'); - error.code = errorCodes.SIGNATURE_VERIFICATION_FAILURE; - throw error; + throw errorWithCode( + new Error('Slack request signing verification failed'), + ErrorCode.SignatureVerificationFailure, + ); } debug('request signing verification success'); @@ -95,24 +96,21 @@ export function createHTTPHandler(adapter) { /** * Request listener used to handle Slack requests and send responses and * verify request signatures - * - * @param {Object} req - Request object - * @param {Object} res - Response object */ - return function slackRequestListener(req, res) { + return (req, res) => { debug('request received - method: %s, path: %s', req.method, req.url); // Function used to send response const respond = sendResponse(res); // Builds body of the request from stream and returns the raw request body getRawBody(req) - .then((r) => { - const rawBody = r.toString(); + .then((bodyBuf) => { + const rawBody = bodyBuf.toString(); - if (verifyRequestSignature(adapter.signingSecret, req.headers, rawBody)) { + if (verifyRequestSignature(adapter.signingSecret, req.headers as Record, rawBody)) { // Request signature is verified // Parse raw body - const body = parseBody(rawBody); + const body = parseBody(rawBody) as any; if (body.ssl_check) { respond({ status: 200 }); @@ -121,7 +119,8 @@ export function createHTTPHandler(adapter) { const dispatchResult = adapter.dispatch(body); - if (dispatchResult) { + if (dispatchResult !== undefined) { + // tslint:disable-next-line no-floating-promises dispatchResult.then(respond); } else { // No callback was matched @@ -130,8 +129,7 @@ export function createHTTPHandler(adapter) { } } }).catch((error) => { - if (error.code === errorCodes.SIGNATURE_VERIFICATION_FAILURE || - error.code === errorCodes.REQUEST_TIME_FAILURE) { + if (error.code === ErrorCode.SignatureVerificationFailure || error.code === ErrorCode.RequestTimeFailure) { respond({ status: 404 }); } else if (process.env.NODE_ENV === 'development') { respond({ status: 500, content: error.message }); @@ -141,3 +139,38 @@ export function createHTTPHandler(adapter) { }); }; } + +export type HTTPHandler = (req: IncomingMessage & { body?: string, rawBody?: Buffer }, res: ServerResponse) => void; + +/** + * A response handler returned by `sendResponse`. + */ +type ResponseHandler = (dispatchResult: { + status: number, + content?: string | object, +}) => void; + +/** + * Parameters for calling {@link verifyRequestSignature}. + */ +export interface VerifyRequestSignatureParams { + /** + * The signing secret used to verify request signature. + */ + signingSecret: string; + + /** + * Signature from the `X-Slack-Signature` header. + */ + requestSignature: string; + + /** + * Timestamp from the `X-Slack-Request-Timestamp` header. + */ + requestTimestamp: number; + + /** + * Full, raw body string. + */ + body: string; +} diff --git a/packages/interactive-messages/src/index.js b/packages/interactive-messages/src/index.ts similarity index 58% rename from packages/interactive-messages/src/index.js rename to packages/interactive-messages/src/index.ts index f8474a19e..b5b26413b 100644 --- a/packages/interactive-messages/src/index.js +++ b/packages/interactive-messages/src/index.ts @@ -1,14 +1,18 @@ /** * @module @slack/interactive-messages */ -import { SlackMessageAdapter, errorCodes as adapterErrorCodes } from './adapter'; + +import { SlackMessageAdapter, MessageAdapterOptions } from './adapter'; +import { ErrorCode } from './errors'; /** * Dictionary of error codes that may appear on errors emitted from this package's objects * @readonly * @enum {string} */ -export const errorCodes = adapterErrorCodes; +export const errorCodes = { + BODY_PARSER_NOT_PERMITTED: ErrorCode.BodyParserNotPermitted, +}; /** * Factory method to create an instance of {@link module:adapter~SlackMessageAdapter} @@ -17,6 +21,6 @@ export const errorCodes = adapterErrorCodes; * @param {Object} options * @returns {module:adapter~SlackMessageAdapter} */ -export function createMessageAdapter(signingSecret, options) { +export function createMessageAdapter(signingSecret: string, options?: MessageAdapterOptions): SlackMessageAdapter { return new SlackMessageAdapter(signingSecret, options); } diff --git a/packages/interactive-messages/src/util.js b/packages/interactive-messages/src/util.js deleted file mode 100644 index 0be8885df..000000000 --- a/packages/interactive-messages/src/util.js +++ /dev/null @@ -1,41 +0,0 @@ -import os from 'os'; -import pkg from '../package.json'; - -function escape(s) { return s.replace('/', ':').replace(' ', '_'); } - -export const errorCodes = { - PROMISE_TIMEOUT: 'SLACKMESSAGEUTIL_PROMISE_TIMEOUT', -}; - -export function promiseTimeout(ms, promise) { - // Create a promise that rejects in milliseconds - const timeout = new Promise((resolve, reject) => { - const id = setTimeout(() => { - clearTimeout(id); - const error = new Error('Promise timed out'); - error.code = errorCodes.PROMISE_TIMEOUT; - reject(error); - }, ms); - }); - // Returns a race between our timeout and the passed in promise - return Promise.race([ - promise, - timeout, - ]); -} - -// NOTE: before this can be an external module: -// 1. are all the JS features supported back to a reasonable version? -// default params, template strings, computed property names -// 2. access to `pkg` will change -// 3. tests -// there will potentially be more named exports in this file -// eslint-disable-next-line import/prefer-default-export -export function packageIdentifier(addons = {}) { - const identifierMap = Object.assign({ - [`${pkg.name}`]: pkg.version, - [`${os.platform()}`]: os.release(), - node: process.version.replace('v', ''), - }, addons); - return Object.keys(identifierMap).reduce((acc, k) => `${acc} ${escape(k)}/${escape(identifierMap[k])}`, ''); -} diff --git a/packages/interactive-messages/test/unit/test-util.js b/packages/interactive-messages/src/util.spec.js similarity index 63% rename from packages/interactive-messages/test/unit/test-util.js rename to packages/interactive-messages/src/util.spec.js index 522fa6e13..805099cc7 100644 --- a/packages/interactive-messages/test/unit/test-util.js +++ b/packages/interactive-messages/src/util.spec.js @@ -1,22 +1,21 @@ -var assert = require('chai').assert; -var systemUnderTest = require('../../dist/util'); -var promiseTimeout = systemUnderTest.promiseTimeout; -var errorCodes = systemUnderTest.errorCodes; -var delayed = require('../helpers').delayed; +require('mocha'); +const { assert } = require('chai'); +const { promiseTimeout, errorCodes } = require('./util'); +const { delayed } = require('../test/helpers'); // test suite describe('promiseTimeout', function () { it('should resolve to input promise value when input resolves faster than timeout', function () { - var value = 'test'; - var input = delayed(10, value); - var output = promiseTimeout(20, input); + const value = 'test'; + const input = delayed(10, value); + const output = promiseTimeout(20, input); return output.then(function (v) { assert.equal(v, value); }); }); it('should reject with error code when input resolves slower than timeout', function () { - var input = delayed(20, 'test'); - var output = promiseTimeout(10, input); + const input = delayed(20, 'test'); + const output = promiseTimeout(10, input); return output.then(function (value) { throw new Error('should not resolve. value: ' + value); }, function (error) { @@ -24,9 +23,9 @@ describe('promiseTimeout', function () { }); }); it('should reject to input error when input rejects faster than timeout', function () { - var reason = 'test'; - var input = delayed(10, undefined, reason); - var output = promiseTimeout(20, input); + const reason = 'test'; + const input = delayed(10, undefined, reason); + const output = promiseTimeout(20, input); return output.then(function (value) { throw new Error('should not resolve. value: ' + value); }, function (error) { @@ -34,9 +33,9 @@ describe('promiseTimeout', function () { }); }); it('should reject with error code when input rejects slower than timeout', function () { - var reason = 'test'; - var input = delayed(20, undefined, reason); - var output = promiseTimeout(10, input); + const reason = 'test'; + const input = delayed(20, undefined, reason); + const output = promiseTimeout(10, input); return output.then(function (value) { throw new Error('should not resolve. value: ' + value); }, function (error) { diff --git a/packages/interactive-messages/src/util.ts b/packages/interactive-messages/src/util.ts new file mode 100644 index 000000000..84880b848 --- /dev/null +++ b/packages/interactive-messages/src/util.ts @@ -0,0 +1,52 @@ +import os from 'os'; +import { ErrorCode, errorWithCode } from './errors'; +const pkg = require('../package.json'); // tslint:disable-line + +function escape(s: string): string { return s.replace('/', ':').replace(' ', '_'); } + +export const errorCodes = { + PROMISE_TIMEOUT: ErrorCode.PromiseTimeout, +}; + +/** + * Creates a timeout on a promise. + * + * @param ms - The timeout duration. + * @param promise - The promise that will timeout. + */ +export function promiseTimeout(ms: number, promise: T | Promise): Promise { + // Create a promise that rejects in `ms` milliseconds + const timeout = new Promise((_resolve, reject) => { + const id = setTimeout( + () => { + clearTimeout(id); + reject(errorWithCode(new Error('Promise timed out'), ErrorCode.PromiseTimeout)); + }, + ms, + ); + }); + + // Race between our timeout and the passed in `promise` + return Promise.race([ + promise as Promise, + timeout, + ]); +} + +// NOTE: before this can be an external module: +// 1. are all the JS features supported back to a reasonable version? +// default params, template strings, computed property names +// 2. access to `pkg` will change +// 3. tests +// there will potentially be more named exports in this file +export function packageIdentifier(addons: Record = {}): string { + const identifierMap = Object.assign( + { + [pkg.name]: pkg.version, + [os.platform()]: os.release(), + node: process.version.replace('v', ''), + }, + addons, + ); + return Object.keys(identifierMap).reduce((acc, k) => `${acc} ${escape(k)}/${escape(identifierMap[k])}`, ''); +} diff --git a/packages/interactive-messages/test/.eslintrc b/packages/interactive-messages/test/.eslintrc deleted file mode 100644 index 546f40bf6..000000000 --- a/packages/interactive-messages/test/.eslintrc +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": [ "airbnb-base/legacy" ], - "env": { - "mocha": true - }, - "parser": "babel-eslint", - "rules": { - "func-names": "off" - } -} diff --git a/packages/interactive-messages/tsconfig.json b/packages/interactive-messages/tsconfig.json new file mode 100644 index 000000000..c21aa5718 --- /dev/null +++ b/packages/interactive-messages/tsconfig.json @@ -0,0 +1,39 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "dist", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + + "moduleResolution": "node", + "baseUrl": ".", + "paths": { + "*": ["./types/*"] + }, + "esModuleInterop" : true, + + // Not using this setting because its only used to require the package.json file, and that would change the + // structure of the files in the dist directory because package.json is not located inside src. It would be nice + // to use import instead of require(), but its not worth the tradeoff of restructuring the build (for now). + // "resolveJsonModule": true, + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "src/**/*.spec.js", + "src/**/*.js" + ], + "jsdoc": { + "out": "support/jsdoc", + "access": "public" + } +} diff --git a/packages/interactive-messages/tslint.json b/packages/interactive-messages/tslint.json new file mode 100644 index 000000000..6937e61da --- /dev/null +++ b/packages/interactive-messages/tslint.json @@ -0,0 +1,70 @@ +{ + "extends": ["tslint-config-airbnb"], + "rules": { + /* modifications to base config */ + // adds statements, members, and elements to the base config + "align": [true, "parameters", "arguments", "statements", "members", "elements"], + // adds number of spaces so auto-fixing will work + "indent": [true, "spaces", 2], + // increase value from 100 in base config to 120 + "max-line-length": [true, 120], + // adds avoid-escape and avoid-template + "quotemark": [true, "single", "avoid-escape", "avoid-template"], + // adds ban-keywords and allow-leading-underscores + // once this gets implemented, we should incorporate it: https://github.com/palantir/tslint/issues/3442 + "variable-name": [true, "ban-keywords", "check-format", "allow-leading-underscore"], + // adds check-module, check-type, check-rest-spread, check-typecast, check-type-operator + "whitespace": [true, + "check-branch", + "check-decl", + "check-operator", + "check-preblock", + "check-type", + "check-module", + "check-separator", + "check-rest-spread", + "check-typecast", + "check-type-operator" + ], + + /* not used in base config */ + "await-promise": true, + "ban-comma-operator": true, + // Disabling the following rule because of https://github.com/palantir/tslint/issues/4493 + // "completed-docs": true, + "interface-over-type-literal": true, + "jsdoc-format": [true, "check-multiline-start"], + "member-access": [true, "check-accessor"], + "no-duplicate-imports": true, + "no-duplicate-switch-case": true, + "no-duplicate-variable": true, + "no-dynamic-delete": true, + "no-empty": true, + "no-floating-promises": true, + "no-for-in-array": true, + "no-implicit-dependencies": true, + "no-object-literal-type-assertion": true, + "no-redundant-jsdoc": true, + "no-require-imports": true, + "no-return-await": true, + "no-submodule-imports": true, + "no-this-assignment": true, + "no-unused-expression": true, + "no-var-requires": true, + "one-line": [true, "check-else", "check-whitespace", "check-open-brace", "check-catch", "check-finally"], + "strict-boolean-expressions": [true, "allow-boolean-or-undefined"], + "typedef": [true, "call-signature"], + "typedef-whitespace": [true, { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }] + // TODO: find a rule similar to https://palantir.github.io/tslint/rules/no-construct/, except it bans those types + // from interfaces (e.g. a function that returns Boolean is an error, it should return boolean) + }, + "linterOptions": { + "format": "verbose" + } +} diff --git a/packages/interactive-messages/types/.gitkeep b/packages/interactive-messages/types/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/packages/interactive-messages/types/lodash.isfunction.d.ts b/packages/interactive-messages/types/lodash.isfunction.d.ts new file mode 100644 index 000000000..78fb140df --- /dev/null +++ b/packages/interactive-messages/types/lodash.isfunction.d.ts @@ -0,0 +1,3 @@ +declare module 'lodash.isfunction' { + export default function(x: any): x is Function; +} \ No newline at end of file diff --git a/packages/interactive-messages/types/lodash.isplainobject.d.ts b/packages/interactive-messages/types/lodash.isplainobject.d.ts new file mode 100644 index 000000000..352ccb29b --- /dev/null +++ b/packages/interactive-messages/types/lodash.isplainobject.d.ts @@ -0,0 +1,3 @@ +declare module 'lodash.isplainobject' { + export default function(x: any): x is object; +} \ No newline at end of file diff --git a/packages/interactive-messages/types/lodash.isregexp.d.ts b/packages/interactive-messages/types/lodash.isregexp.d.ts new file mode 100644 index 000000000..f73d163d7 --- /dev/null +++ b/packages/interactive-messages/types/lodash.isregexp.d.ts @@ -0,0 +1,3 @@ +declare module 'lodash.isregexp' { + export default function(x: any): x is RegExp; +} \ No newline at end of file diff --git a/packages/interactive-messages/types/lodash.isstring.d.ts b/packages/interactive-messages/types/lodash.isstring.d.ts new file mode 100644 index 000000000..1a5466e1d --- /dev/null +++ b/packages/interactive-messages/types/lodash.isstring.d.ts @@ -0,0 +1,3 @@ +declare module 'lodash.isstring' { + export default function(x: any): x is string; +} \ No newline at end of file diff --git a/packages/interactive-messages/types/tsscmp.d.ts b/packages/interactive-messages/types/tsscmp.d.ts new file mode 100644 index 000000000..2de5e631c --- /dev/null +++ b/packages/interactive-messages/types/tsscmp.d.ts @@ -0,0 +1,4 @@ +declare module 'tsscmp' { + function timingSafeCompare(sessionToken: string, givenToken: string): boolean; + export = timingSafeCompare; +} From 70f25a792b76c58c3aa5e73bdca3db96817239ef Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Wed, 12 Jun 2019 15:20:51 -0700 Subject: [PATCH 07/25] Add missing `shx` dependency --- packages/interactive-messages/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/interactive-messages/package.json b/packages/interactive-messages/package.json index 66f7b6dd1..f90d2a208 100644 --- a/packages/interactive-messages/package.json +++ b/packages/interactive-messages/package.json @@ -70,6 +70,7 @@ "nop": "^1.0.0", "nyc": "^11.6.0", "proxyquire": "^2.0.1", + "shx": "^0.3.2", "sinon": "^4.5.0", "source-map-support": "^0.5.12", "ts-node": "^8.2.0", From 7b6ce9902eb0ccd56ffba95a09e2e3aecff8fd10 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Wed, 12 Jun 2019 15:35:57 -0700 Subject: [PATCH 08/25] Fix linting `@slack/interactive-messages` --- packages/interactive-messages/src/errors.ts | 2 +- packages/interactive-messages/src/index.ts | 14 ++------------ 2 files changed, 3 insertions(+), 13 deletions(-) diff --git a/packages/interactive-messages/src/errors.ts b/packages/interactive-messages/src/errors.ts index 87e1253ba..8fd1edad7 100644 --- a/packages/interactive-messages/src/errors.ts +++ b/packages/interactive-messages/src/errors.ts @@ -27,4 +27,4 @@ export function errorWithCode(error: Error, code: ErrorCode): CodedError { const codedError = error as CodedError; codedError.code = code; return codedError; -} \ No newline at end of file +} diff --git a/packages/interactive-messages/src/index.ts b/packages/interactive-messages/src/index.ts index b5b26413b..a2e621dde 100644 --- a/packages/interactive-messages/src/index.ts +++ b/packages/interactive-messages/src/index.ts @@ -1,25 +1,15 @@ -/** - * @module @slack/interactive-messages - */ - import { SlackMessageAdapter, MessageAdapterOptions } from './adapter'; import { ErrorCode } from './errors'; /** * Dictionary of error codes that may appear on errors emitted from this package's objects - * @readonly - * @enum {string} */ export const errorCodes = { BODY_PARSER_NOT_PERMITTED: ErrorCode.BodyParserNotPermitted, -}; +} as const; /** - * Factory method to create an instance of {@link module:adapter~SlackMessageAdapter} - * - * @param {string} signingSecret - * @param {Object} options - * @returns {module:adapter~SlackMessageAdapter} + * Factory method to create an instance of {@link SlackMessageAdapter} */ export function createMessageAdapter(signingSecret: string, options?: MessageAdapterOptions): SlackMessageAdapter { return new SlackMessageAdapter(signingSecret, options); From 62e2b453749e01e6770fe6bd54e8100c29334bf2 Mon Sep 17 00:00:00 2001 From: Chris Opperwall Date: Fri, 28 Jun 2019 15:41:02 -0700 Subject: [PATCH 09/25] test(WebClient): add test cases for invalid Retry-After headers This adds test cases for when there's no Retry-After header on a 429 response and for when there's an invalid Retry-After header on a 429 response. This also includes a test case to assert that buildResult includes retrySec in the response_metadata object of the data response. This test case is kind of finicky though, because it looks like in the case of a 429 response, that particular block isn't run because the makeRequest function either throws an error if rejectRateLimitedCalls is true or waits and trys again, in which case there won't be a Retry-After header on the non-429 response after the retry timeout ends. --- packages/web-api/src/WebClient.spec.js | 40 +++++++++++++++++++++++++- packages/web-api/src/WebClient.ts | 6 +++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/web-api/src/WebClient.spec.js b/packages/web-api/src/WebClient.spec.js index 71863922d..b658ecabf 100644 --- a/packages/web-api/src/WebClient.spec.js +++ b/packages/web-api/src/WebClient.spec.js @@ -817,6 +817,18 @@ describe('WebClient', function () { }); }); + it('should set retrySec info on the response_metadata object', function () { + const scope = nock('https://slack.com') + .post(/api/) + .reply(200, { ok: true }, { 'retry-after': 100 }); + const client = new WebClient(token); + return client.apiCall('method') + .then((data) => { + assert(data.response_metadata.retryAfter === 100); + scope.done(); + }); + }); + it('should pause the remaining requests in queue', function () { const startTime = Date.now(); const retryAfter = 1; @@ -856,9 +868,35 @@ describe('WebClient', function () { done(); }); }); - // TODO: when parsing the retry header fails }); + it('should throw an error if the response has no retry info', function (done) { + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, {}, { 'retry-after': undefined }); + const client = new WebClient(token); + client.apiCall('method') + .catch((err) => { + assert(err.message.match(/Retry header did not contain a valid timeout/i) !== null); + scope.done(); + done(); + }); + }); + + it('should throw an error if the response has an invalid retry-after header', function (done) { + const scope = nock('https://slack.com') + .post(/api/) + .reply(429, {}, { 'retry-after': 'notanumber' }); + const client = new WebClient(token); + client.apiCall('method') + .catch((err) => { + assert(err.message.match(/Retry header did not contain a valid timeout/i) !== null); + scope.done(); + done(); + }); + }); + + afterEach(function () { nock.cleanAll(); }); diff --git a/packages/web-api/src/WebClient.ts b/packages/web-api/src/WebClient.ts index e51fd90b3..189a548e2 100644 --- a/packages/web-api/src/WebClient.ts +++ b/packages/web-api/src/WebClient.ts @@ -828,7 +828,11 @@ function paginationOptionsForNextPage( */ function parseRetryHeaders(response: AxiosResponse): number | undefined { if (response.headers['retry-after'] !== undefined) { - return parseInt((response.headers['retry-after'] as string), 10); + const retryAfter = parseInt((response.headers['retry-after'] as string), 10); + + if (!Number.isNaN(retryAfter)) { + return retryAfter; + } } return undefined; } From cc32ba7a4ee8b21cfe553d2c490a700110e118e0 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Mon, 1 Jul 2019 15:17:17 -0700 Subject: [PATCH 10/25] Iterate on changes - Add more verbose disabled linting boundaries - Use existing `@types/` packages for lodash - Replace previous checks for falsy-ness or truthy-ness with a helper function - Remove unused parameters - Cleaned up interactive messages types --- packages/events-api/package.json | 4 +- packages/events-api/src/adapter.spec.js | 2 +- packages/events-api/src/adapter.ts | 50 +++-- packages/events-api/src/http-handler.ts | 25 +-- packages/events-api/src/util.ts | 10 + packages/events-api/src/verify.ts | 2 +- packages/events-api/tsconfig.json | 2 +- packages/interactive-messages/package.json | 7 +- .../interactive-messages/src/adapter.spec.js | 6 +- packages/interactive-messages/src/adapter.ts | 184 ++++++++++-------- .../interactive-messages/src/http-handler.ts | 40 +--- packages/interactive-messages/src/index.ts | 1 + packages/interactive-messages/src/util.ts | 10 + packages/interactive-messages/tsconfig.json | 2 +- .../types/lodash.isfunction.d.ts | 3 - .../types/lodash.isplainobject.d.ts | 3 - .../types/lodash.isregexp.d.ts | 3 - .../types/lodash.isstring.d.ts | 3 - 18 files changed, 187 insertions(+), 170 deletions(-) delete mode 100644 packages/interactive-messages/types/lodash.isfunction.d.ts delete mode 100644 packages/interactive-messages/types/lodash.isplainobject.d.ts delete mode 100644 packages/interactive-messages/types/lodash.isregexp.d.ts delete mode 100644 packages/interactive-messages/types/lodash.isstring.d.ts diff --git a/packages/events-api/package.json b/packages/events-api/package.json index 2b531b6ad..4c0944269 100644 --- a/packages/events-api/package.json +++ b/packages/events-api/package.json @@ -43,14 +43,16 @@ "coverage": "codecov -F eventsapi --root=$PWD" }, "dependencies": { + "@types/node": ">=4.2.0", "debug": "^2.6.1", + "lodash.isstring": "^4.0.1", "raw-body": "^2.3.3", "tsscmp": "^1.0.6", "yargs": "^6.6.0" }, "devDependencies": { "@types/debug": "^4.1.4", - "@types/node": "^12.0.6", + "@types/lodash.isstring": "^4.0.6", "@types/yargs": "^13.0.0", "chai": "^4.2.0", "codecov": "^3.0.4", diff --git a/packages/events-api/src/adapter.spec.js b/packages/events-api/src/adapter.spec.js index b8a04dc1b..a419b33b8 100644 --- a/packages/events-api/src/adapter.spec.js +++ b/packages/events-api/src/adapter.spec.js @@ -20,7 +20,7 @@ describe('SlackEventAdapter', function () { }); it('should fail without a signing secret', function () { assert.throws(function () { - const adapter = new SlackEventAdapter(); // eslint-disable-line no-unused-vars + const adapter = new SlackEventAdapter(); }, TypeError); }); it('should store the signing secret', function () { diff --git a/packages/events-api/src/adapter.ts b/packages/events-api/src/adapter.ts index 080eaa318..31ba5cbe9 100644 --- a/packages/events-api/src/adapter.ts +++ b/packages/events-api/src/adapter.ts @@ -1,7 +1,11 @@ -import EventEmitter from 'events'; // tslint:disable-line +/* tslint:disable import-name */ +import EventEmitter from 'events'; import http, { IncomingMessage, ServerResponse } from 'http'; -import debugFactory from 'debug'; // tslint:disable-line +import debugFactory from 'debug'; +import isString from 'lodash.isstring'; import { createHTTPHandler } from './http-handler'; +import { isFalsy } from './util'; +/* tslint:enable import-name */ const debug = debugFactory('@slack/events-api:adapter'); @@ -15,17 +19,17 @@ export class SlackEventAdapter extends EventEmitter { public readonly signingSecret: string; /** - * Whether to include the API event bodies in adapter event consumers. + * Whether to include the API event bodies in adapter event listeners. */ public includeBody: boolean; /** - * Whether to include request headers in adapter event consumers. + * Whether to include request headers in adapter event listeners. */ public includeHeaders: boolean; /** - * When `true`, prevents the adapter from responding by itself and leaves that up to consumers. + * When `true` prevents the adapter from responding by itself and leaves that up to listeners. */ public waitForResponse: boolean; @@ -36,25 +40,26 @@ export class SlackEventAdapter extends EventEmitter { /** * @param signingSecret - The token used to authenticate signed requests from Slack's Events API. - * @param opts.includeBody - TODO: - * @param opts.includeHeaders - TODO: - * @param opts.waitForResponse - TODO: + * @param opts.includeBody - Whether to include the API event bodies in adapter event listeners. + * @param opts.includeHeaders - Whether to include request headers in adapter event listeners. + * @param opts.waitForResponse - When `true` prevents the adapter from responding by itself and leaves that up to + * listeners. */ constructor( - signingSecret: string | String, + signingSecret: string, { includeBody = false, includeHeaders = false, waitForResponse = false, }: EventAdapterOptions = {}, ) { - if (typeof signingSecret !== 'string' && !(signingSecret instanceof String)) { + if (!isString(signingSecret)) { throw new TypeError('SlackEventAdapter needs a signing secret'); } super(); - this.signingSecret = signingSecret as string; + this.signingSecret = signingSecret; this.includeBody = includeBody; this.includeHeaders = includeHeaders; this.waitForResponse = waitForResponse; @@ -68,15 +73,11 @@ export class SlackEventAdapter extends EventEmitter { /** * Creates an HTTP server to listen for event payloads. - * - * @param path - (UNUSED) The path to listen on. */ - public createServer(path: string = '/slack/events'): Promise { + public createServer(): Promise { // TODO: options (like https) - // NOTE: this is a workaround for a shortcoming of the System.import() tranform + // NOTE: this was once a workaround for a shortcoming of the System.import() tranform return Promise.resolve().then(() => { - debug('server created - path: %s', path); - return http.createServer(this.requestListener()); }); } @@ -102,10 +103,10 @@ export class SlackEventAdapter extends EventEmitter { */ public stop(): Promise { return new Promise((resolve, reject) => { - if (this.server !== undefined) { + if (!isFalsy(this.server)) { this.server.close((error) => { delete this.server; - if (error !== undefined) { + if (!isFalsy(error)) { reject(error); } else { resolve(); @@ -119,12 +120,9 @@ export class SlackEventAdapter extends EventEmitter { /** * Returns a middleware-compatible adapter. - * @param middlewareOptions - (UNUSED) */ - public expressMiddleware( - middlewareOptions: object = {}, - ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { - const requestListener = this.requestListener(middlewareOptions); + public expressMiddleware(): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { + const requestListener = this.requestListener(); return (req, res, _next) => { requestListener(req, res); }; @@ -132,10 +130,8 @@ export class SlackEventAdapter extends EventEmitter { /** * Creates a request listener. - * - * @param middlewareOptions - (UNUSED) */ - public requestListener(_middlewareOptions = {}): (req: IncomingMessage, res: ServerResponse) => void { + public requestListener(): (req: IncomingMessage, res: ServerResponse) => void { return createHTTPHandler(this); } } diff --git a/packages/events-api/src/http-handler.ts b/packages/events-api/src/http-handler.ts index be2cd502d..fa10f6c81 100644 --- a/packages/events-api/src/http-handler.ts +++ b/packages/events-api/src/http-handler.ts @@ -3,9 +3,10 @@ import debugFactory from 'debug'; import getRawBody from 'raw-body'; import crypto from 'crypto'; import timingSafeCompare from 'tsscmp'; -import { packageIdentifier } from './util'; +import { packageIdentifier, isFalsy } from './util'; import SlackEventAdapter from './adapter'; import { IncomingMessage, ServerResponse } from 'http'; +/* tslint:enable:import-name */ const debug = debugFactory('@slack/events-api:http-handler'); @@ -59,18 +60,20 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { return (err, responseOptions) => { debug('sending response - error: %s, responseOptions: %o', err, responseOptions); // Deal with errors up front - if (err !== undefined) { + if (!isFalsy(err)) { if ('status' in err) { res.statusCode = err.status; - } else if (err.code === ErrorCode.SignatureVerificationFailure || - err.code === ErrorCode.RequestTimeFailure) { + } else if ('code' in err && ( + err.code === ErrorCode.SignatureVerificationFailure || + err.code === ErrorCode.RequestTimeFailure + )) { res.statusCode = ResponseStatus.NotFound; } else { res.statusCode = ResponseStatus.Failure; } } else { // First determine the response status - if (responseOptions !== undefined) { + if (!isFalsy(responseOptions)) { if (responseOptions.failWithNoRetry) { res.statusCode = ResponseStatus.Failure; } else if (responseOptions.redirectLocation) { @@ -84,14 +87,14 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { } // Next determine the response headers - if (responseOptions !== undefined && responseOptions.failWithNoRetry) { + if (!isFalsy(responseOptions) && responseOptions.failWithNoRetry) { res.setHeader('X-Slack-No-Retry', '1'); } res.setHeader('X-Slack-Powered-By', poweredBy); } // Lastly, send the response - if (responseOptions !== undefined && responseOptions.content) { + if (!isFalsy(responseOptions) && responseOptions.content) { res.end(responseOptions.content); } else { res.end(); @@ -137,7 +140,7 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { // If parser is being used and we don't receive the raw payload via `rawBody`, // we can't verify request signature - if (req.body !== undefined && req.rawBody === undefined) { + if (!isFalsy(req.body) && isFalsy(req.rawBody)) { handleError( errorWithCode( new Error('Parsing request body prohibits request signature verification'), @@ -153,7 +156,7 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { // To prevent throwing an error here, we check the `rawBody` field before parsing the request // through the `raw-body` module (see Issue #85 - https://github.com/slackapi/node-slack-events-api/issues/85) let parseRawBody: Promise; - if (req.rawBody !== undefined) { + if (!isFalsy(req.rawBody)) { debug('Parsing request with a rawBody attribute'); parseRawBody = Promise.resolve(req.rawBody); } else { @@ -211,12 +214,12 @@ enum ResponseStatus { Failure = 500, } -type HTTPHandler = (req: IncomingMessage & { body?: string, rawBody?: Buffer }, res: ServerResponse) => void; +type HTTPHandler = (req: IncomingMessage & { body?: any, rawBody?: Buffer }, res: ServerResponse) => void; /** * A response handler returned by `sendResponse`. */ -type ResponseHandler = (err?: (Error & Partial>) | { status: number }, responseOptions?: { +export type ResponseHandler = (err?: Error | CodedError | { status: number }, responseOptions?: { failWithNoRetry?: boolean; redirectLocation?: boolean; content?: any; diff --git a/packages/events-api/src/util.ts b/packages/events-api/src/util.ts index 373d91ff0..d5d177012 100644 --- a/packages/events-api/src/util.ts +++ b/packages/events-api/src/util.ts @@ -7,3 +7,13 @@ export function packageIdentifier(): string { return `${pkg.name.replace('/', ':')}/${pkg.version} ${os.platform()}/${os.release()} ` + `node/${process.version.replace('v', '')}`; } + +/** + * Tests a "thing" for being falsy. See: https://developer.mozilla.org/en-US/docs/Glossary/Falsy + * + * @param x - The "thing" whose falsy-ness to test. + */ +export function isFalsy(x: any): x is 0 | '' | null | undefined { + // NOTE: there's no way to type `x is NaN` currently (as of TypeScript v3.5) + return x === 0 || x === '' || x === null || x === undefined || (typeof x === 'number' && isNaN(x)); +} diff --git a/packages/events-api/src/verify.ts b/packages/events-api/src/verify.ts index 8a982a01b..69d8a004b 100755 --- a/packages/events-api/src/verify.ts +++ b/packages/events-api/src/verify.ts @@ -33,7 +33,7 @@ const argv = yargs const slackEvents = createEventAdapter(argv.secret); slackEvents - .createServer(argv.path) + .createServer() .then(server => new Promise((resolve, reject) => { server.on('error', reject); server.listen(argv.port, () => { diff --git a/packages/events-api/tsconfig.json b/packages/events-api/tsconfig.json index c21aa5718..b64fbed6c 100644 --- a/packages/events-api/tsconfig.json +++ b/packages/events-api/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2017", + "target": "es5", "module": "commonjs", "declaration": true, "declarationMap": true, diff --git a/packages/interactive-messages/package.json b/packages/interactive-messages/package.json index f90d2a208..d5abf2de6 100644 --- a/packages/interactive-messages/package.json +++ b/packages/interactive-messages/package.json @@ -47,6 +47,7 @@ "coverage": "codecov -F interactivemessages --root=$PWD" }, "dependencies": { + "@types/node": ">=4.2.0", "axios": "^0.18.0", "debug": "^3.1.0", "lodash.isfunction": "^3.0.9", @@ -58,7 +59,11 @@ }, "devDependencies": { "@types/debug": "^4.1.4", - "@types/node": "^12.0.8", + "@types/express": "^4.17.0", + "@types/lodash.isfunction": "^3.0.6", + "@types/lodash.isplainobject": "^4.0.6", + "@types/lodash.isregexp": "^4.0.6", + "@types/lodash.isstring": "^4.0.6", "body-parser": "^1.18.2", "chai": "^4.2.0", "codecov": "^3.0.0", diff --git a/packages/interactive-messages/src/adapter.spec.js b/packages/interactive-messages/src/adapter.spec.js index 2abde2d5b..50a34a7a8 100644 --- a/packages/interactive-messages/src/adapter.spec.js +++ b/packages/interactive-messages/src/adapter.spec.js @@ -23,7 +23,7 @@ describe('SlackMessageAdapter', function () { }); it('should fail without a signing secret', function () { assert.throws(function () { - const adapter = new SlackMessageAdapter(); // eslint-disable-line no-unused-vars + const adapter = new SlackMessageAdapter(); }, TypeError); }); it('should allow configuring of the synchronous response timeout', function () { @@ -35,11 +35,9 @@ describe('SlackMessageAdapter', function () { }); it('should fail when the synchronous response timeout is out of range', function () { assert.throws(function () { - // eslint-disable-next-line no-unused-vars const a = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: 0 }); }, TypeError); assert.throws(function () { - // eslint-disable-next-line no-unused-vars const a = new SlackMessageAdapter(workingSigningSecret, { syncResponseTimeout: 3001 }); }, TypeError); }); @@ -190,7 +188,7 @@ describe('SlackMessageAdapter', function () { * @param {SlackMessageAdapter} adapter */ function unregisterAllHandlers(adapter) { - adapter.callbacks = []; // eslint-disable-line no-param-reassign + adapter.callbacks = []; } // shared tests diff --git a/packages/interactive-messages/src/adapter.ts b/packages/interactive-messages/src/adapter.ts index d3d51c50e..a8f8bfa0c 100644 --- a/packages/interactive-messages/src/adapter.ts +++ b/packages/interactive-messages/src/adapter.ts @@ -1,5 +1,5 @@ /* tslint:disable import-name */ -import http from 'http'; +import http, { RequestListener } from 'http'; import axios, { AxiosInstance } from 'axios'; import isString from 'lodash.isstring'; import isPlainObject from 'lodash.isplainobject'; @@ -7,8 +7,10 @@ import isRegExp from 'lodash.isregexp'; import isFunction from 'lodash.isfunction'; import debugFactory from 'debug'; import { ErrorCode, errorWithCode, CodedError } from './errors'; -import { createHTTPHandler, HTTPHandler } from './http-handler'; -import { packageIdentifier, promiseTimeout } from './util'; +import { createHTTPHandler } from './http-handler'; +import { packageIdentifier, promiseTimeout, isFalsy } from './util'; +import { RequestHandler } from 'express'; // tslint:disable-line no-implicit-dependencies - only a type is imported +/* tslint:enable import-name */ const debug = debugFactory('@slack/interactive-messages:adapter'); @@ -17,15 +19,17 @@ const debug = debugFactory('@slack/interactive-messages:adapter'); * @param matchingConstraints - the various forms of matching constraints accepted * @returns an object where each matching constraint is a property */ -function formatMatchingConstraints(matchingConstraints: string | RegExp | object): MatchingConstraints { - let ret: any = {}; +function formatMatchingConstraints(matchingConstraints: string | RegExp | ActionConstraints): ActionConstraints; +function formatMatchingConstraints(matchingConstraints: string | RegExp | OptionsConstraints): OptionsConstraints; +function formatMatchingConstraints(matchingConstraints: string | RegExp | BaseConstraints): BaseConstraints { + let ret: BaseConstraints = {}; if (matchingConstraints === undefined || matchingConstraints === null) { throw new TypeError('Constraints cannot be undefined or null'); } if (!isPlainObject(matchingConstraints)) { - ret.callbackId = matchingConstraints; + ret.callbackId = matchingConstraints as string | RegExp; } else { - ret = Object.assign({}, matchingConstraints); + ret = Object.assign({}, matchingConstraints as BaseConstraints); } return ret; } @@ -35,18 +39,18 @@ function formatMatchingConstraints(matchingConstraints: string | RegExp | object * @param matchingConstraints - object describing the constraints on a callback * @returns `false` represents successful validation, an error represents failure and describes why validation failed. */ -function validateConstraints(matchingConstraints: CallbackConstraints): Error | false { - if (matchingConstraints.callbackId !== undefined && +function validateConstraints(matchingConstraints: BaseConstraints): Error | false { + if (!isFalsy(matchingConstraints.callbackId) && !(isString(matchingConstraints.callbackId) || isRegExp(matchingConstraints.callbackId))) { return new TypeError('Callback ID must be a string or RegExp'); } - if (matchingConstraints.blockId !== undefined && + if (!isFalsy(matchingConstraints.blockId) && !(isString(matchingConstraints.blockId) || isRegExp(matchingConstraints.blockId))) { return new TypeError('Block ID must be a string or RegExp'); } - if (matchingConstraints.actionId !== undefined && + if (!isFalsy(matchingConstraints.actionId) && !(isString(matchingConstraints.actionId) || isRegExp(matchingConstraints.actionId))) { return new TypeError('Action ID must be a string or RegExp'); } @@ -56,11 +60,11 @@ function validateConstraints(matchingConstraints: CallbackConstraints): Error | /** * Validates properties of a matching constraints object specific to registering an options request - * @param matchingConstraints - object describing the constraints on a callback + * @param matchingConstraints - object describing the constraints on an options handler * @returns `false` represents successful validation, an error represents failure and describes why validation failed. */ -function validateOptionsConstraints(optionsConstraints: any): Error | false { - if (optionsConstraints.within !== undefined && +function validateOptionsConstraints(optionsConstraints: OptionsConstraints): Error | false { + if (!isFalsy(optionsConstraints.within) && !(optionsConstraints.within === 'interactive_message' || optionsConstraints.within === 'block_actions' || optionsConstraints.within === 'dialog') @@ -94,7 +98,7 @@ export class SlackMessageAdapter { */ public lateResponseFallbackEnabled: boolean; - private callbacks: [MatchingConstraints, ActionHandler][]; + private callbacks: ConstrainedHandler[]; private axios: AxiosInstance; private server?: http.Server; @@ -139,19 +143,14 @@ export class SlackMessageAdapter { * Create a server that dispatches Slack's interactive message actions and menu requests to this message adapter * instance. Use this method if your application will handle starting the server. * - * @param path - The path portion of the URL where the server will listen for requests from Slack's interactive - * messages. * @returns A promise that resolves to an instance of http.Server and will dispatch interactive message actions and * options requests to this message adapter instance. See * https://nodejs.org/dist/latest/docs/api/http.html#http_class_http_server */ - public createServer(path: string = '/slack/actions'): Promise { + public createServer(): Promise { // TODO: more options (like https) - return Promise.resolve().then(() => { - debug('server created - path: %s', path); - - return http.createServer(this.requestListener()); - }); + // NOTE: this was once a workaround for a shortcoming of the System.import() tranform + return Promise.resolve().then(() => http.createServer(this.requestListener())); } /** @@ -177,10 +176,10 @@ export class SlackMessageAdapter { */ public stop(): Promise { return new Promise((resolve, reject) => { - if (this.server !== undefined) { + if (!isFalsy(this.server)) { this.server.close((error) => { delete this.server; - if (error !== undefined) { + if (!isFalsy(error)) { reject(error); } else { resolve(); @@ -200,15 +199,11 @@ export class SlackMessageAdapter { * * @returns A middleware function (see http://expressjs.com/en/guide/using-middleware.html) */ - public expressMiddleware(): ( - req: http.IncomingMessage & { body?: string }, - res: http.ServerResponse, - next: Function, - ) => void { + public expressMiddleware(): RequestHandler { const requestListener = this.requestListener(); return (req, res, next) => { // If parser is being used, we can't verify request signature - if (req.body !== undefined) { + if (!isFalsy(req.body)) { next(errorWithCode( new Error('Parsing request body prohibits request signature verification'), ErrorCode.BodyParserNotPermitted, @@ -222,7 +217,7 @@ export class SlackMessageAdapter { /** * Create a request listener function that handles HTTP requests, verifies requests and dispatches responses */ - public requestListener(): HTTPHandler { + public requestListener(): RequestListener { return createHTTPHandler(this); } @@ -249,11 +244,10 @@ export class SlackMessageAdapter { */ /* tslint:enable max-line-length */ public action( - matchingConstraints: string | RegExp | CallbackConstraints, + matchingConstraints: string | RegExp | ActionConstraints, callback: ActionHandler, ): SlackMessageAdapter { - /* eslint-enable max-len */ - const actionConstraints = formatMatchingConstraints(matchingConstraints); + const actionConstraints = formatMatchingConstraints(matchingConstraints) as ConstrainedActionHandler[0]; actionConstraints.handlerType = 'action'; const error = validateConstraints(actionConstraints); @@ -283,11 +277,10 @@ export class SlackMessageAdapter { */ /* tslint:enable max-line-length */ public options( - matchingConstraints: string | RegExp | CallbackConstraints, + matchingConstraints: string | RegExp | OptionsConstraints, callback: OptionsHandler, ): SlackMessageAdapter { - /* eslint-enable max-len */ - const optionsConstraints = formatMatchingConstraints(matchingConstraints); + const optionsConstraints = formatMatchingConstraints(matchingConstraints) as ConstrainedOptionsHandler[0]; optionsConstraints.handlerType = 'options'; const error = validateConstraints(optionsConstraints) || @@ -305,13 +298,17 @@ export class SlackMessageAdapter { /** * Dispatches the contents of an HTTP request to the registered handlers. * + * @remarks + * This is an internal API not meant to be used by code depending on this package. + * + * @internal * @returns A promise of the response information (an object with status and content that is a JSON serializable * object or a string or undefined) for the request. An undefined return value indicates that the request was not * matched. */ - public dispatch(payload: any): Promise<{ status: number, content?: object | string }> | undefined { + public dispatch(payload: any): Promise | undefined { const callback = this.matchCallback(payload); - if (callback === undefined) { + if (isFalsy(callback)) { debug('dispatch could not find a handler'); return undefined; } @@ -319,7 +316,7 @@ export class SlackMessageAdapter { const [, callbackFn] = callback; // when a response_url is present,`respond()` function created to to send a message using it - const respond: Respond | undefined = payload.response_url ? (message: object): Promise => { + const respond: Respond | undefined = payload.response_url ? (message: any): Promise => { if (typeof (message as any).then === 'function') { throw new TypeError('Cannot use a Promise as the parameter for respond()'); } @@ -327,18 +324,18 @@ export class SlackMessageAdapter { return this.axios.post(payload.response_url, message); } : undefined; - let callbackResult: ReturnType; + let callbackResult: ReturnType; try { callbackResult = callbackFn.call(this, payload, respond as Respond); } catch (error) { debug('callback error: %o', error); - return Promise.resolve({ status: 500 }); + return Promise.resolve({ status: ResponseStatus.Failure }); } - if (callbackResult !== undefined) { + if (!isFalsy(callbackResult)) { return promiseTimeout(this.syncResponseTimeout, callbackResult) - .then(content => ({ content, status: 200 })) - .catch((error: CodedError) => { + .then(content => ({ content, status: ResponseStatus.Ok })) + .catch((error: CodedError) => { if (error.code === ErrorCode.PromiseTimeout) { // warn and continue for promises that cannot be saved with a later async response. // this includes dialog submissions because the response_url doesn't have the same @@ -346,22 +343,22 @@ export class SlackMessageAdapter { // if this has been explicitly disabled in the configuration. if (!this.lateResponseFallbackEnabled || respond === undefined || payload.type === 'dialog_submission') { debug('WARNING: The response Promise did not resolve under the timeout.'); - return (callbackResult as Promise) - .then(content => ({ content, status: 200 })) - .catch(() => ({ status: 500 })); + return (callbackResult as Promise) + .then(content => ({ content, status: ResponseStatus.Ok })) + .catch(() => ({ status: ResponseStatus.Failure })); } // save a late promise by sending an empty body in the response, and then use the // response_url to send the eventually resolved value - (callbackResult as Promise).then(respond).catch((callbackError) => { + (callbackResult as Promise).then(respond).catch((callbackError) => { // when the promise is late and fails, we cannot do anything but log it debug('ERROR: Promise was late and failed. Use `.catch()` to handle errors.'); throw callbackError; }); - return { status: 200 }; + return { status: ResponseStatus.Ok }; } - return { status: 500 }; + return { status: ResponseStatus.Failure }; }); } @@ -372,22 +369,22 @@ export class SlackMessageAdapter { return Promise.resolve({ status: 200 }); } - private registerCallback(constraints: MatchingConstraints, callback: ActionHandler): SlackMessageAdapter { + private registerCallback(constraints: ConstrainedHandler[0], callback: ConstrainedHandler[1]): SlackMessageAdapter { // Validation if (!isFunction(callback)) { debug('did not register callback because its not a function'); throw new TypeError('callback must be a function'); } - this.callbacks.push([constraints, callback]); + this.callbacks.push([constraints, callback] as ConstrainedHandler); return this; } - private matchCallback(payload: any): [MatchingConstraints, ActionHandler] | undefined { + private matchCallback(payload: any): ConstrainedHandler | undefined { return this.callbacks.find(([constraints]) => { // if the callback ID constraint is specified, only continue if it matches - if (constraints.callbackId !== undefined) { + if (!isFalsy(constraints.callbackId)) { if (isString(constraints.callbackId) && payload.callback_id !== constraints.callbackId) { return false; } @@ -408,7 +405,7 @@ export class SlackMessageAdapter { const action = payload.actions ? payload.actions[0] : {}; // if the block ID constraint is specified, only continue if it matches - if (constraints.blockId !== undefined) { + if (!isFalsy(constraints.blockId)) { if (isString(constraints.blockId) && action.block_id !== constraints.blockId) { return false; } @@ -418,7 +415,7 @@ export class SlackMessageAdapter { } // if the action ID constraint is specified, only continue if it matches - if (constraints.actionId !== undefined) { + if (!isFalsy(constraints.actionId)) { if (isString(constraints.actionId) && action.action_id !== constraints.actionId) { return false; } @@ -431,13 +428,13 @@ export class SlackMessageAdapter { // actions have a type defined at the top level, and select actions don't have a type // defined, but type can be inferred by checking if a `selected_options` property exists in // the action. - // tslint:disable-next-line + // tslint:disable-next-line strict-boolean-expressions const type = action.type || payload.type || (action.selected_options && 'select'); if (!type) { debug('no type found in dispatched action'); } // if the type constraint is specified, only continue if it matches - if (constraints.type !== undefined && constraints.type !== type) { + if (!isFalsy(constraints.type) && constraints.type !== type) { return false; } @@ -460,7 +457,7 @@ export class SlackMessageAdapter { } // if the block ID constraint is specified, only continue if it matches - if (constraints.blockId !== undefined) { + if (!isFalsy(constraints.blockId)) { if (isString(constraints.blockId) && payload.block_id !== constraints.blockId) { return false; } @@ -470,7 +467,7 @@ export class SlackMessageAdapter { } // if the action ID constraint is specified, only continue if it matches - if (constraints.actionId !== undefined) { + if (!isFalsy(constraints.actionId)) { if (isString(constraints.actionId) && payload.action_id !== constraints.actionId) { return false; } @@ -484,7 +481,7 @@ export class SlackMessageAdapter { // * type:interactive_message => within:interactive_message // * type:block_suggestion => within:block_actions // * type:dialog_suggestion => within:dialog - if (constraints.within !== undefined) { + if (!isFalsy(constraints.within)) { if (constraints.within === 'interactive_message' && payload.type !== 'interactive_message') { return false; } @@ -503,10 +500,21 @@ export class SlackMessageAdapter { } } +export default SlackMessageAdapter; + +/** Some HTTP response statuses. */ +enum ResponseStatus { + Ok = 200, + Failure = 500, +} + /** - * @alias module:adapter + * The result of a call to {@link SlackMessageAdapter#dispatch}. */ -export default SlackMessageAdapter; +interface DispatchResult { + status: ResponseStatus; + content?: any; +} /** * Options for constructing {@link SlackMessageAdapter}. @@ -517,9 +525,9 @@ export interface MessageAdapterOptions { } /** - * Constraints that determine when a callback is fired. + * Constraints that apply to actions and options handlers. */ -interface CallbackConstraints { +interface BaseConstraints { /** * A string or RegExp to match against the `callback_id` */ @@ -534,13 +542,18 @@ interface CallbackConstraints { * A string or RegExp to match against the `action_id` */ actionId?: string | RegExp; +} +/** + * Constraints on when to call an action handler. + */ +export interface ActionConstraints extends BaseConstraints { /** - * valid types include all + * Valid types include all * [actions block elements](https://api.slack.com/reference/messaging/interactive-components), * `select` only for menu selections, or `dialog_submission` only for dialog submissions */ - type?: string; + type?: ''; /** * When `true` only match actions from an unfurl @@ -549,12 +562,29 @@ interface CallbackConstraints { } /** - * A matchable/testable set of constraints. + * Constraints on when to call an options handler. + */ +export interface OptionsConstraints extends BaseConstraints { + /** + * The source of options request. + */ + within: 'block_actions' | 'interactive_message' | 'dialog'; +} + +/** + * An action handler that has a set of constraints attached to it. + */ +type ConstrainedActionHandler = [ActionConstraints & { handlerType: 'action' }, ActionHandler]; + +/** + * An options handler that has a set of constraints attached to it. + */ +type ConstrainedOptionsHandler = [OptionsConstraints & { handlerType: 'options' }, OptionsHandler]; + +/** + * An action or options handler that has its respective kind of constraint alongside it. */ -type MatchingConstraints = CallbackConstraints & { - handlerType: string; - within: 'interactive_message' | 'block_actions' | 'dialog'; -}; +type ConstrainedHandler = ConstrainedActionHandler | ConstrainedOptionsHandler; /** * A handler function for action requests (block actions, button presses, menu selections, @@ -578,7 +608,7 @@ type MatchingConstraints = CallbackConstraints & { * complete, Slack will display an error to the user. If there is no return value, then button presses and menu * selections do not update the message and dialog submissions will validate and dismiss. */ -type ActionHandler = (payload: object, respond: Respond) => object | Promise | undefined; +type ActionHandler = (payload: any, respond: Respond) => any | Promise | undefined; /** * A function used to send message updates after an action is handled. This function can be used @@ -589,7 +619,7 @@ type ActionHandler = (payload: object, respond: Respond) => object | Promise Promise; +type Respond = (message: any) => Promise; /** * A handler function for menu options requests. @@ -604,4 +634,4 @@ type Respond = (message: any) => Promise; * Promise either of these values. If a Promise is returned and it does not complete within 3 seconds, Slack will * display an error to the user. If there is no return value, then the user is shown an empty list of options. */ -type OptionsHandler = (payload: object) => object | Promise | undefined; +type OptionsHandler = (payload: any) => any | Promise | undefined; diff --git a/packages/interactive-messages/src/http-handler.ts b/packages/interactive-messages/src/http-handler.ts index 92a66e2c1..a6b91dc41 100644 --- a/packages/interactive-messages/src/http-handler.ts +++ b/packages/interactive-messages/src/http-handler.ts @@ -1,5 +1,5 @@ /* tslint:disable import-name */ -import { IncomingMessage, ServerResponse } from 'http'; +import { ServerResponse, RequestListener } from 'http'; import * as querystring from 'querystring'; import debugFactory from 'debug'; import getRawBody from 'raw-body'; @@ -7,11 +7,11 @@ import crypto from 'crypto'; import timingSafeCompare from 'tsscmp'; import SlackMessageAdapter from './adapter'; import { ErrorCode, errorWithCode } from './errors'; -import { packageIdentifier } from './util'; +import { packageIdentifier, isFalsy } from './util'; const debug = debugFactory('@slack/interactive-messages:http-handler'); -export function createHTTPHandler(adapter: SlackMessageAdapter): HTTPHandler { +export function createHTTPHandler(adapter: SlackMessageAdapter): RequestListener { const poweredBy = packageIdentifier(); /** @@ -26,7 +26,7 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): HTTPHandler { res.setHeader('X-Slack-Powered-By', poweredBy); if (typeof content === 'string') { res.end(content); - } else if (content !== undefined) { + } else if (!isFalsy(content)) { res.setHeader('Content-Type', 'application/json'); res.end(JSON.stringify(content)); } else { @@ -41,9 +41,9 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): HTTPHandler { * @param body - Raw body of request * @returns Parsed body of the request */ - function parseBody(body: string): object { + function parseBody(body: string): any { const parsedBody = querystring.parse(body); - if (parsedBody.payload !== undefined) { + if (!isFalsy(parsedBody.payload)) { return JSON.parse(parsedBody.payload as string); } @@ -120,6 +120,7 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): HTTPHandler { const dispatchResult = adapter.dispatch(body); if (dispatchResult !== undefined) { + // TODO: handle this after responding? // tslint:disable-next-line no-floating-promises dispatchResult.then(respond); } else { @@ -140,8 +141,6 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): HTTPHandler { }; } -export type HTTPHandler = (req: IncomingMessage & { body?: string, rawBody?: Buffer }, res: ServerResponse) => void; - /** * A response handler returned by `sendResponse`. */ @@ -149,28 +148,3 @@ type ResponseHandler = (dispatchResult: { status: number, content?: string | object, }) => void; - -/** - * Parameters for calling {@link verifyRequestSignature}. - */ -export interface VerifyRequestSignatureParams { - /** - * The signing secret used to verify request signature. - */ - signingSecret: string; - - /** - * Signature from the `X-Slack-Signature` header. - */ - requestSignature: string; - - /** - * Timestamp from the `X-Slack-Request-Timestamp` header. - */ - requestTimestamp: number; - - /** - * Full, raw body string. - */ - body: string; -} diff --git a/packages/interactive-messages/src/index.ts b/packages/interactive-messages/src/index.ts index a2e621dde..2470fbdac 100644 --- a/packages/interactive-messages/src/index.ts +++ b/packages/interactive-messages/src/index.ts @@ -7,6 +7,7 @@ import { ErrorCode } from './errors'; export const errorCodes = { BODY_PARSER_NOT_PERMITTED: ErrorCode.BodyParserNotPermitted, } as const; +// TODO: export other error codes /** * Factory method to create an instance of {@link SlackMessageAdapter} diff --git a/packages/interactive-messages/src/util.ts b/packages/interactive-messages/src/util.ts index 84880b848..2e821832b 100644 --- a/packages/interactive-messages/src/util.ts +++ b/packages/interactive-messages/src/util.ts @@ -50,3 +50,13 @@ export function packageIdentifier(addons: Record = {}): string { ); return Object.keys(identifierMap).reduce((acc, k) => `${acc} ${escape(k)}/${escape(identifierMap[k])}`, ''); } + +/** + * Tests a "thing" for being falsy. See: https://developer.mozilla.org/en-US/docs/Glossary/Falsy + * + * @param x - The "thing" whose falsy-ness to test. + */ +export function isFalsy(x: any): x is 0 | '' | null | undefined { + // NOTE: there's no way to type `x is NaN` currently (as of TypeScript v3.5) + return x === 0 || x === '' || x === null || x === undefined || (typeof x === 'number' && isNaN(x)); +} diff --git a/packages/interactive-messages/tsconfig.json b/packages/interactive-messages/tsconfig.json index c21aa5718..b64fbed6c 100644 --- a/packages/interactive-messages/tsconfig.json +++ b/packages/interactive-messages/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "target": "es2017", + "target": "es5", "module": "commonjs", "declaration": true, "declarationMap": true, diff --git a/packages/interactive-messages/types/lodash.isfunction.d.ts b/packages/interactive-messages/types/lodash.isfunction.d.ts deleted file mode 100644 index 78fb140df..000000000 --- a/packages/interactive-messages/types/lodash.isfunction.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'lodash.isfunction' { - export default function(x: any): x is Function; -} \ No newline at end of file diff --git a/packages/interactive-messages/types/lodash.isplainobject.d.ts b/packages/interactive-messages/types/lodash.isplainobject.d.ts deleted file mode 100644 index 352ccb29b..000000000 --- a/packages/interactive-messages/types/lodash.isplainobject.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'lodash.isplainobject' { - export default function(x: any): x is object; -} \ No newline at end of file diff --git a/packages/interactive-messages/types/lodash.isregexp.d.ts b/packages/interactive-messages/types/lodash.isregexp.d.ts deleted file mode 100644 index f73d163d7..000000000 --- a/packages/interactive-messages/types/lodash.isregexp.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'lodash.isregexp' { - export default function(x: any): x is RegExp; -} \ No newline at end of file diff --git a/packages/interactive-messages/types/lodash.isstring.d.ts b/packages/interactive-messages/types/lodash.isstring.d.ts deleted file mode 100644 index 1a5466e1d..000000000 --- a/packages/interactive-messages/types/lodash.isstring.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -declare module 'lodash.isstring' { - export default function(x: any): x is string; -} \ No newline at end of file From 3c31e5f3e433dd8816b823addc3f30568f67801e Mon Sep 17 00:00:00 2001 From: beeme1mr Date: Sat, 6 Jul 2019 20:08:29 -0400 Subject: [PATCH 11/25] Add KnownBlock to incoming webhook argument Added KnownBlock to the blocks property for incoming webhooks. This change bring the webhook configuration in line with the MessageAttachment type. https://github.com/slackapi/node-slack-sdk/blob/master/packages/types/src/index.ts#L183 --- packages/webhook/src/IncomingWebhook.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/webhook/src/IncomingWebhook.ts b/packages/webhook/src/IncomingWebhook.ts index 326b31049..50ce14503 100644 --- a/packages/webhook/src/IncomingWebhook.ts +++ b/packages/webhook/src/IncomingWebhook.ts @@ -2,7 +2,7 @@ import { Agent } from 'http'; import axios, { AxiosInstance, AxiosResponse } from 'axios'; import { httpErrorWithOriginal, requestErrorWithOriginal } from './errors'; import { getUserAgent } from './instrument'; -import { MessageAttachment, Block } from '@slack/types'; +import { MessageAttachment, Block, KnownBlock } from '@slack/types'; /** * A client for Slack's Incoming Webhooks @@ -100,7 +100,7 @@ export interface IncomingWebhookDefaultArguments { export interface IncomingWebhookSendArguments extends IncomingWebhookDefaultArguments { attachments?: MessageAttachment[]; - blocks?: Block[]; + blocks?: (KnownBlock | Block)[]; unfurl_links?: boolean; unfurl_media?: boolean; } From f245ba4958155e5a929f86a9d801f5a7ee4fea0f Mon Sep 17 00:00:00 2001 From: Rajashekar Chintalapati Date: Fri, 12 Jul 2019 11:30:24 -0700 Subject: [PATCH 12/25] refactor: correcting documentation of https-proxy-agent --- docs/_packages/rtm_api.md | 2 +- docs/_packages/web_api.md | 2 +- docs/_packages/webhook.md | 2 +- packages/webhook/README.md | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/_packages/rtm_api.md b/docs/_packages/rtm_api.md index 6717097db..8171fcb5f 100644 --- a/docs/_packages/rtm_api.md +++ b/docs/_packages/rtm_api.md @@ -550,7 +550,7 @@ Import the `HttpsProxyAgent` class, and create an instance that can be used as t ```javascript const { RTMClient } = require('@slack/rtm-api'); -const { HttpsProxyAgent } = require('https-proxy-agent'); +const HttpsProxyAgent = require('https-proxy-agent'); const token = process.env.SLACK_BOT_TOKEN; // One of the ways you can configure HttpsProxyAgent is using a simple string. diff --git a/docs/_packages/web_api.md b/docs/_packages/web_api.md index 81d8a8ba1..cbd0183b7 100644 --- a/docs/_packages/web_api.md +++ b/docs/_packages/web_api.md @@ -415,7 +415,7 @@ Import the `HttpsProxyAgent` class, and create an instance that can be used as t ```javascript const { WebClient } = require('@slack/web-api'); -const { HttpsProxyAgent } = require('https-proxy-agent'); +const HttpsProxyAgent = require('https-proxy-agent'); const token = process.env.SLACK_TOKEN; // One of the ways you can configure HttpsProxyAgent is using a simple string. diff --git a/docs/_packages/webhook.md b/docs/_packages/webhook.md index fabd8d067..5d1049ef5 100644 --- a/docs/_packages/webhook.md +++ b/docs/_packages/webhook.md @@ -107,7 +107,7 @@ Import the `HttpsProxyAgent` class, and create an instance that can be used as t ```javascript const { IncomingWebhook } = require('@slack/webhook'); -const { HttpsProxyAgent } = require('https-proxy-agent'); +const HttpsProxyAgent = require('https-proxy-agent'); const url = process.env.SLACK_WEBHOOK_URL; // One of the ways you can configure HttpsProxyAgent is using a simple string. diff --git a/packages/webhook/README.md b/packages/webhook/README.md index 3837186f9..b874c70b8 100644 --- a/packages/webhook/README.md +++ b/packages/webhook/README.md @@ -105,7 +105,7 @@ Import the `HttpsProxyAgent` class, and create an instance that can be used as t ```javascript const { IncomingWebhook } = require('@slack/webhook'); -const { HttpsProxyAgent } = require('https-proxy-agent'); +const HttpsProxyAgent = require('https-proxy-agent'); const url = process.env.SLACK_WEBHOOK_URL; // One of the ways you can configure HttpsProxyAgent is using a simple string. From d54766ccc903360c84e6c5bad21b58e6ce959026 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Wed, 17 Jul 2019 12:29:50 -0700 Subject: [PATCH 13/25] Iterate on types changes * De-spaghetti interactive messages types * More defined types * Cleaning up duplicate changes * Moved all `@types/*` deps to `dependencies` (see #830) --- packages/events-api/package.json | 6 +- packages/events-api/src/adapter.ts | 11 +- packages/events-api/src/http-handler.ts | 2 +- packages/interactive-messages/package.json | 12 +- packages/interactive-messages/src/adapter.ts | 123 ++++++++++-------- .../interactive-messages/src/http-handler.ts | 18 ++- 6 files changed, 96 insertions(+), 76 deletions(-) diff --git a/packages/events-api/package.json b/packages/events-api/package.json index 4c0944269..81ac64559 100644 --- a/packages/events-api/package.json +++ b/packages/events-api/package.json @@ -43,7 +43,10 @@ "coverage": "codecov -F eventsapi --root=$PWD" }, "dependencies": { + "@types/debug": "^4.1.4", + "@types/lodash.isstring": "^4.0.6", "@types/node": ">=4.2.0", + "@types/yargs": "^13.0.0", "debug": "^2.6.1", "lodash.isstring": "^4.0.1", "raw-body": "^2.3.3", @@ -51,9 +54,6 @@ "yargs": "^6.6.0" }, "devDependencies": { - "@types/debug": "^4.1.4", - "@types/lodash.isstring": "^4.0.6", - "@types/yargs": "^13.0.0", "chai": "^4.2.0", "codecov": "^3.0.4", "express": "^4.14.0", diff --git a/packages/events-api/src/adapter.ts b/packages/events-api/src/adapter.ts index 31ba5cbe9..01f678260 100644 --- a/packages/events-api/src/adapter.ts +++ b/packages/events-api/src/adapter.ts @@ -1,6 +1,6 @@ /* tslint:disable import-name */ import EventEmitter from 'events'; -import http, { IncomingMessage, ServerResponse } from 'http'; +import http, { IncomingMessage, ServerResponse, RequestListener } from 'http'; import debugFactory from 'debug'; import isString from 'lodash.isstring'; import { createHTTPHandler } from './http-handler'; @@ -74,12 +74,9 @@ export class SlackEventAdapter extends EventEmitter { /** * Creates an HTTP server to listen for event payloads. */ - public createServer(): Promise { + public async createServer(): Promise { // TODO: options (like https) - // NOTE: this was once a workaround for a shortcoming of the System.import() tranform - return Promise.resolve().then(() => { - return http.createServer(this.requestListener()); - }); + return http.createServer(this.requestListener()); } /** @@ -131,7 +128,7 @@ export class SlackEventAdapter extends EventEmitter { /** * Creates a request listener. */ - public requestListener(): (req: IncomingMessage, res: ServerResponse) => void { + public requestListener(): RequestListener { return createHTTPHandler(this); } } diff --git a/packages/events-api/src/http-handler.ts b/packages/events-api/src/http-handler.ts index fa10f6c81..c82b6ed13 100644 --- a/packages/events-api/src/http-handler.ts +++ b/packages/events-api/src/http-handler.ts @@ -219,7 +219,7 @@ type HTTPHandler = (req: IncomingMessage & { body?: any, rawBody?: Buffer }, res /** * A response handler returned by `sendResponse`. */ -export type ResponseHandler = (err?: Error | CodedError | { status: number }, responseOptions?: { +export type ResponseHandler = (err?: Error | CodedError | { status: ResponseStatus }, responseOptions?: { failWithNoRetry?: boolean; redirectLocation?: boolean; content?: any; diff --git a/packages/interactive-messages/package.json b/packages/interactive-messages/package.json index d5abf2de6..04cf1a801 100644 --- a/packages/interactive-messages/package.json +++ b/packages/interactive-messages/package.json @@ -47,23 +47,21 @@ "coverage": "codecov -F interactivemessages --root=$PWD" }, "dependencies": { + "@types/debug": "^4.1.4", + "@types/express": "^4.17.0", + "@types/lodash.isfunction": "^3.0.6", + "@types/lodash.isregexp": "^4.0.6", + "@types/lodash.isstring": "^4.0.6", "@types/node": ">=4.2.0", "axios": "^0.18.0", "debug": "^3.1.0", "lodash.isfunction": "^3.0.9", - "lodash.isplainobject": "^4.0.6", "lodash.isregexp": "^4.0.1", "lodash.isstring": "^4.0.1", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" }, "devDependencies": { - "@types/debug": "^4.1.4", - "@types/express": "^4.17.0", - "@types/lodash.isfunction": "^3.0.6", - "@types/lodash.isplainobject": "^4.0.6", - "@types/lodash.isregexp": "^4.0.6", - "@types/lodash.isstring": "^4.0.6", "body-parser": "^1.18.2", "chai": "^4.2.0", "codecov": "^3.0.0", diff --git a/packages/interactive-messages/src/adapter.ts b/packages/interactive-messages/src/adapter.ts index a8f8bfa0c..6f4b031d8 100644 --- a/packages/interactive-messages/src/adapter.ts +++ b/packages/interactive-messages/src/adapter.ts @@ -2,7 +2,6 @@ import http, { RequestListener } from 'http'; import axios, { AxiosInstance } from 'axios'; import isString from 'lodash.isstring'; -import isPlainObject from 'lodash.isplainobject'; import isRegExp from 'lodash.isregexp'; import isFunction from 'lodash.isfunction'; import debugFactory from 'debug'; @@ -19,19 +18,18 @@ const debug = debugFactory('@slack/interactive-messages:adapter'); * @param matchingConstraints - the various forms of matching constraints accepted * @returns an object where each matching constraint is a property */ -function formatMatchingConstraints(matchingConstraints: string | RegExp | ActionConstraints): ActionConstraints; -function formatMatchingConstraints(matchingConstraints: string | RegExp | OptionsConstraints): OptionsConstraints; -function formatMatchingConstraints(matchingConstraints: string | RegExp | BaseConstraints): BaseConstraints { - let ret: BaseConstraints = {}; +function formatMatchingConstraints(matchingConstraints: string | RegExp | C): C { if (matchingConstraints === undefined || matchingConstraints === null) { throw new TypeError('Constraints cannot be undefined or null'); } - if (!isPlainObject(matchingConstraints)) { - ret.callbackId = matchingConstraints as string | RegExp; + + let ret: AnyConstraints = {}; + if (typeof matchingConstraints === 'string' || matchingConstraints instanceof RegExp) { + ret.callbackId = matchingConstraints; } else { - ret = Object.assign({}, matchingConstraints as BaseConstraints); + ret = Object.assign({}, matchingConstraints); } - return ret; + return ret as C; } /** @@ -39,7 +37,7 @@ function formatMatchingConstraints(matchingConstraints: string | RegExp | BaseCo * @param matchingConstraints - object describing the constraints on a callback * @returns `false` represents successful validation, an error represents failure and describes why validation failed. */ -function validateConstraints(matchingConstraints: BaseConstraints): Error | false { +function validateConstraints(matchingConstraints: AnyConstraints): Error | false { if (!isFalsy(matchingConstraints.callbackId) && !(isString(matchingConstraints.callbackId) || isRegExp(matchingConstraints.callbackId))) { return new TypeError('Callback ID must be a string or RegExp'); @@ -98,7 +96,7 @@ export class SlackMessageAdapter { */ public lateResponseFallbackEnabled: boolean; - private callbacks: ConstrainedHandler[]; + private callbacks: [StoredConstraints, Callback][]; private axios: AxiosInstance; private server?: http.Server; @@ -147,10 +145,9 @@ export class SlackMessageAdapter { * options requests to this message adapter instance. See * https://nodejs.org/dist/latest/docs/api/http.html#http_class_http_server */ - public createServer(): Promise { + public async createServer(): Promise { // TODO: more options (like https) - // NOTE: this was once a workaround for a shortcoming of the System.import() tranform - return Promise.resolve().then(() => http.createServer(this.requestListener())); + return http.createServer(this.requestListener()); } /** @@ -246,9 +243,11 @@ export class SlackMessageAdapter { public action( matchingConstraints: string | RegExp | ActionConstraints, callback: ActionHandler, - ): SlackMessageAdapter { - const actionConstraints = formatMatchingConstraints(matchingConstraints) as ConstrainedActionHandler[0]; - actionConstraints.handlerType = 'action'; + ): this { + const actionConstraints = formatMatchingConstraints(matchingConstraints); + const storableConstraints = Object.assign(actionConstraints, { + handlerType: StoredConstraintsType.Action as const, + }); const error = validateConstraints(actionConstraints); if (error) { @@ -256,7 +255,7 @@ export class SlackMessageAdapter { throw error; } - return this.registerCallback(actionConstraints, callback); + return this.registerCallback(storableConstraints, callback); } /* tslint:disable max-line-length */ @@ -279,9 +278,11 @@ export class SlackMessageAdapter { public options( matchingConstraints: string | RegExp | OptionsConstraints, callback: OptionsHandler, - ): SlackMessageAdapter { - const optionsConstraints = formatMatchingConstraints(matchingConstraints) as ConstrainedOptionsHandler[0]; - optionsConstraints.handlerType = 'options'; + ): this { + const optionsConstraints = formatMatchingConstraints(matchingConstraints); + const storableConstraints = Object.assign(optionsConstraints, { + handlerType: StoredConstraintsType.Options as const, + }); const error = validateConstraints(optionsConstraints) || validateOptionsConstraints(optionsConstraints); @@ -290,7 +291,7 @@ export class SlackMessageAdapter { throw error; } - return this.registerCallback(optionsConstraints, callback); + return this.registerCallback(storableConstraints, callback); } /* Interface for HTTP servers (like express middleware) */ @@ -324,7 +325,7 @@ export class SlackMessageAdapter { return this.axios.post(payload.response_url, message); } : undefined; - let callbackResult: ReturnType; + let callbackResult: any; try { callbackResult = callbackFn.call(this, payload, respond as Respond); } catch (error) { @@ -369,19 +370,19 @@ export class SlackMessageAdapter { return Promise.resolve({ status: 200 }); } - private registerCallback(constraints: ConstrainedHandler[0], callback: ConstrainedHandler[1]): SlackMessageAdapter { + private registerCallback(constraints: StoredConstraints, callback: Callback): this { // Validation if (!isFunction(callback)) { debug('did not register callback because its not a function'); throw new TypeError('callback must be a function'); } - this.callbacks.push([constraints, callback] as ConstrainedHandler); + this.callbacks.push([constraints, callback]); return this; } - private matchCallback(payload: any): ConstrainedHandler | undefined { + private matchCallback(payload: any): [StoredConstraints, Callback] | undefined { return this.callbacks.find(([constraints]) => { // if the callback ID constraint is specified, only continue if it matches if (!isFalsy(constraints.callbackId)) { @@ -394,7 +395,7 @@ export class SlackMessageAdapter { } // if the action constraint is specified, only continue if it matches - if (constraints.handlerType === 'action') { + if (constraints.handlerType === StoredConstraintsType.Action) { // a payload that represents an action either has actions, submission, or message defined if (!(payload.actions || payload.submission || payload.message)) { return false; @@ -449,7 +450,7 @@ export class SlackMessageAdapter { } } - if (constraints.handlerType === 'options') { + if (constraints.handlerType === StoredConstraintsType.Options) { // a payload that represents an options request in attachments always has a name defined // at the top level. in blocks the type is block_suggestion and has no name if (!('name' in payload || (payload.type && payload.type === 'block_suggestion'))) { @@ -525,9 +526,9 @@ export interface MessageAdapterOptions { } /** - * Constraints that apply to actions and options handlers. + * Constraints on when to call an action handler. */ -interface BaseConstraints { +export interface ActionConstraints { /** * A string or RegExp to match against the `callback_id` */ @@ -542,18 +543,13 @@ interface BaseConstraints { * A string or RegExp to match against the `action_id` */ actionId?: string | RegExp; -} -/** - * Constraints on when to call an action handler. - */ -export interface ActionConstraints extends BaseConstraints { /** * Valid types include all * [actions block elements](https://api.slack.com/reference/messaging/interactive-components), * `select` only for menu selections, or `dialog_submission` only for dialog submissions */ - type?: ''; + type?: string; /** * When `true` only match actions from an unfurl @@ -564,27 +560,55 @@ export interface ActionConstraints extends BaseConstraints { /** * Constraints on when to call an options handler. */ -export interface OptionsConstraints extends BaseConstraints { +export interface OptionsConstraints { + /** + * A string or RegExp to match against the `callback_id` + */ + callbackId?: string | RegExp; + + /** + * A string or RegExp to match against the `block_id` + */ + blockId?: string | RegExp; + + /** + * A string or RegExp to match against the `action_id` + */ + actionId?: string | RegExp; + /** * The source of options request. */ within: 'block_actions' | 'interactive_message' | 'dialog'; } +type AnyConstraints = ActionConstraints | OptionsConstraints; + /** - * An action handler that has a set of constraints attached to it. + * The type of stored constraints. */ -type ConstrainedActionHandler = [ActionConstraints & { handlerType: 'action' }, ActionHandler]; +const enum StoredConstraintsType { + Action = 'action', + Options = 'options', +} /** - * An options handler that has a set of constraints attached to it. + * Internal storage type that describes the constraints of an ActionHandler. */ -type ConstrainedOptionsHandler = [OptionsConstraints & { handlerType: 'options' }, OptionsHandler]; +type StoredConstraints = + | ({ handlerType: StoredConstraintsType.Action } & ActionConstraints) + | ({ handlerType: StoredConstraintsType.Options } & OptionsConstraints); /** - * An action or options handler that has its respective kind of constraint alongside it. + * A function used to send message updates after an action is handled. This function can be used + * up to 5 times in 30 minutes. + * + * @param message - a [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields). + * Dialog submissions do not allow `resplace_original: false` on this message. @returnsthere's no contract or + * interface for the resolution value, but this Promise will resolve when the HTTP response from the `response_url` + * request is complete and reject when there is an error. */ -type ConstrainedHandler = ConstrainedActionHandler | ConstrainedOptionsHandler; +type Respond = (message: any) => Promise; /** * A handler function for action requests (block actions, button presses, menu selections, @@ -610,17 +634,6 @@ type ConstrainedHandler = ConstrainedActionHandler | ConstrainedOptionsHandler; */ type ActionHandler = (payload: any, respond: Respond) => any | Promise | undefined; -/** - * A function used to send message updates after an action is handled. This function can be used - * up to 5 times in 30 minutes. - * - * @param message - a [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields). - * Dialog submissions do not allow `resplace_original: false` on this message. @returnsthere's no contract or - * interface for the resolution value, but this Promise will resolve when the HTTP response from the `response_url` - * request is complete and reject when there is an error. - */ -type Respond = (message: any) => Promise; - /** * A handler function for menu options requests. * @@ -635,3 +648,5 @@ type Respond = (message: any) => Promise; * display an error to the user. If there is no return value, then the user is shown an empty list of options. */ type OptionsHandler = (payload: any) => any | Promise | undefined; + +type Callback = ActionHandler | OptionsHandler; diff --git a/packages/interactive-messages/src/http-handler.ts b/packages/interactive-messages/src/http-handler.ts index a6b91dc41..189e8405e 100644 --- a/packages/interactive-messages/src/http-handler.ts +++ b/packages/interactive-messages/src/http-handler.ts @@ -1,5 +1,5 @@ /* tslint:disable import-name */ -import { ServerResponse, RequestListener } from 'http'; +import { ServerResponse, RequestListener, IncomingHttpHeaders } from 'http'; import * as querystring from 'querystring'; import debugFactory from 'debug'; import getRawBody from 'raw-body'; @@ -54,13 +54,13 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): RequestListener * Method to verify signature of requests * * @param signingSecret - Signing secret used to verify request signature - * @param requestHeaders - Request headers + * @param requestHeaders - The signing headers. If `rew` is an incoming request, then this should be `req.headers`. * @param body - Raw body string * @returns Indicates if request is verified */ function verifyRequestSignature( signingSecret: string, - requestHeaders: Record, + requestHeaders: VerificationHeaders, body: string, ): boolean { // Request signature @@ -107,7 +107,7 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): RequestListener .then((bodyBuf) => { const rawBody = bodyBuf.toString(); - if (verifyRequestSignature(adapter.signingSecret, req.headers as Record, rawBody)) { + if (verifyRequestSignature(adapter.signingSecret, req.headers as VerificationHeaders, rawBody)) { // Request signature is verified // Parse raw body const body = parseBody(rawBody) as any; @@ -148,3 +148,13 @@ type ResponseHandler = (dispatchResult: { status: number, content?: string | object, }) => void; + +/** + * Headers required for verification. + * + * See: https://api.slack.com/docs/verifying-requests-from-slack + */ +export interface VerificationHeaders extends IncomingHttpHeaders { + 'x-slack-signature': string; + 'x-slack-request-timestamp': string; +} From f4ea2c6b305bad1876769e8908566953e636f956 Mon Sep 17 00:00:00 2001 From: Chris Opperwall Date: Sat, 20 Jul 2019 15:53:53 -0700 Subject: [PATCH 14/25] test(WebClient.spec.js): update test case description for including retryAfter data --- packages/web-api/src/WebClient.spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-api/src/WebClient.spec.js b/packages/web-api/src/WebClient.spec.js index b658ecabf..e31f5dcbf 100644 --- a/packages/web-api/src/WebClient.spec.js +++ b/packages/web-api/src/WebClient.spec.js @@ -817,7 +817,7 @@ describe('WebClient', function () { }); }); - it('should set retrySec info on the response_metadata object', function () { + it('should include retryAfter metadata if the response has retry info', function () { const scope = nock('https://slack.com') .post(/api/) .reply(200, { ok: true }, { 'retry-after': 100 }); From d4821d63c9de94cc7842f95bf4ef5a12dd54d643 Mon Sep 17 00:00:00 2001 From: Chris Opperwall Date: Sun, 21 Jul 2019 17:33:58 -0700 Subject: [PATCH 15/25] fix(package.json): update form-data to 2.5.0, removes @types/form-data I noticed that the Travis CI build was failing due to a missing type definition for form-data in the WebAPI package. It looks like the current release of form-data includes its own type-definitions, which means that @types/form-data isn't necessary anymore. Removing @types/form-data and updating form-data to 2.5.0 fixed the lerna bootstrap command. --- packages/web-api/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/web-api/package.json b/packages/web-api/package.json index cd2fcf24c..e42a83bdf 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -43,13 +43,12 @@ "dependencies": { "@slack/logger": "^1.0.0", "@slack/types": "^1.0.0", - "@types/form-data": "^2.2.1", "@types/is-stream": "^1.1.0", "@types/node": ">=8.9.0", "@types/p-queue": "^2.3.2", "axios": "^0.18.0", "eventemitter3": "^3.1.0", - "form-data": "^2.3.3", + "form-data": "^2.5.0", "is-stream": "^1.1.0", "p-queue": "^2.4.2", "p-retry": "^4.0.0" From 8269366f89420d6828e1f8b676c4e52d87ad707c Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 14 Jul 2019 18:46:32 +0900 Subject: [PATCH 16/25] Bump events-api deps to fix security warnings npm install --dev nyc@14.1.1 $ npm audit | grep Run Run npm install --dev nyc@14.1.1 to resolve 31 vulnerabilities Run npm update set-value --depth 10 to resolve 5 vulnerabilities Run npm update union-value --depth 10 to resolve 5 vulnerabilities Run npm update mixin-deep --depth 9 to resolve 5 vulnerabilities --- packages/events-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/events-api/package.json b/packages/events-api/package.json index dee073bd1..0f326f4bc 100644 --- a/packages/events-api/package.json +++ b/packages/events-api/package.json @@ -66,7 +66,7 @@ "lodash.isfunction": "^3.0.8", "mocha": "^5.2.0", "nop": "^1.0.0", - "nyc": "^12.0.2", + "nyc": "^14.1.1", "proxyquire": "^1.7.10", "sinon": "^4.5.0", "superagent": "^3.3.1", From 13b908b34228a60046b2bf91f6379f77a8c288b7 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 14 Jul 2019 18:49:22 +0900 Subject: [PATCH 17/25] Bump interactive-messages deps to fix security warnings npm install --dev nyc@14.1.1 npm install --dev jsdoc-to-markdown@5.0.0 $ npm audit | grep Run Run npm install --dev nyc@14.1.1 to resolve 40 vulnerabilities Run npm install --dev jsdoc-to-markdown@5.0.0 to resolve 2 vulnerabilities --- packages/interactive-messages/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/interactive-messages/package.json b/packages/interactive-messages/package.json index 79029fef1..1ef47ef25 100644 --- a/packages/interactive-messages/package.json +++ b/packages/interactive-messages/package.json @@ -68,10 +68,10 @@ "eslint-plugin-import": "^2.7.0", "estraverse": "^4.2.0", "get-random-port": "0.0.1", - "jsdoc-to-markdown": "^4.0.1", + "jsdoc-to-markdown": "^5.0.0", "mocha": "^5.0.5", "nop": "^1.0.0", - "nyc": "^11.6.0", + "nyc": "^14.1.1", "proxyquire": "^2.0.1", "sinon": "^4.5.0" } From a23c267354281350dd0dd98b812f85e71680d152 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 14 Jul 2019 18:51:25 +0900 Subject: [PATCH 18/25] Bump web-api deps to fix security warnings npm install --dev nyc@14.1.1 $ npm audit | grep Run Run npm install --dev nyc@14.1.1 to resolve 1 vulnerability --- packages/web-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web-api/package.json b/packages/web-api/package.json index e42a83bdf..917689637 100644 --- a/packages/web-api/package.json +++ b/packages/web-api/package.json @@ -62,7 +62,7 @@ "codecov": "^3.2.0", "mocha": "^6.0.2", "nock": "^10.0.6", - "nyc": "^13.3.0", + "nyc": "^14.1.1", "shelljs": "^0.8.3", "shx": "^0.3.2", "sinon": "^7.2.7", From fa7a745c24f438fd8b6b6902376a8c792c7ea991 Mon Sep 17 00:00:00 2001 From: Kazuhiro Sera Date: Sun, 14 Jul 2019 18:52:17 +0900 Subject: [PATCH 19/25] Bump webhook deps to fix security warnings npm install --dev nyc@14.1.1 $ npm audit | grep Run Run npm install --dev nyc@14.1.1 to resolve 1 vulnerability --- packages/webhook/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webhook/package.json b/packages/webhook/package.json index 73f7bfdd5..bc0a996af 100644 --- a/packages/webhook/package.json +++ b/packages/webhook/package.json @@ -49,7 +49,7 @@ "codecov": "^3.2.0", "mocha": "^6.0.2", "nock": "^10.0.6", - "nyc": "^13.3.0", + "nyc": "^14.1.1", "shx": "^0.3.2", "sinon": "^7.2.7", "source-map-support": "^0.5.10", From c9dd6db9a16dbf82e1b6bcfabb67202f77941c5a Mon Sep 17 00:00:00 2001 From: Chris Opperwall Date: Mon, 22 Jul 2019 21:53:39 -0700 Subject: [PATCH 20/25] test(WebClient.spec.js): update assertion to assert error type, not message --- packages/web-api/src/WebClient.spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web-api/src/WebClient.spec.js b/packages/web-api/src/WebClient.spec.js index e31f5dcbf..23ed6dfed 100644 --- a/packages/web-api/src/WebClient.spec.js +++ b/packages/web-api/src/WebClient.spec.js @@ -877,7 +877,7 @@ describe('WebClient', function () { const client = new WebClient(token); client.apiCall('method') .catch((err) => { - assert(err.message.match(/Retry header did not contain a valid timeout/i) !== null); + assert.instanceOf(err, Error); scope.done(); done(); }); @@ -890,7 +890,7 @@ describe('WebClient', function () { const client = new WebClient(token); client.apiCall('method') .catch((err) => { - assert(err.message.match(/Retry header did not contain a valid timeout/i) !== null); + assert.instanceOf(err, Error); scope.done(); done(); }); From f73fa10b4d997b6ccd8744e4779a7ff903042ed4 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Tue, 23 Jul 2019 10:12:53 -0700 Subject: [PATCH 21/25] Iterate on changes - Use `RequestHandler` in events-api - Retype `respond` error to `ResponseError` - Re-add lodash.isPlainObject to fix failing tests - Rearrange `action` and `options` to catch errors earlier - Remove unnecessary cast - Add extra time to test timeout --- packages/events-api/package.json | 1 + packages/events-api/src/adapter.ts | 5 +++-- packages/events-api/src/http-handler.spec.js | 1 - packages/events-api/src/http-handler.ts | 20 ++++++++++++------ packages/interactive-messages/package.json | 2 ++ .../interactive-messages/src/adapter.spec.js | 2 +- packages/interactive-messages/src/adapter.ts | 21 +++++++++---------- .../interactive-messages/src/http-handler.ts | 2 +- 8 files changed, 32 insertions(+), 22 deletions(-) diff --git a/packages/events-api/package.json b/packages/events-api/package.json index 81ac64559..1be15692c 100644 --- a/packages/events-api/package.json +++ b/packages/events-api/package.json @@ -44,6 +44,7 @@ }, "dependencies": { "@types/debug": "^4.1.4", + "@types/express": "^4.17.0", "@types/lodash.isstring": "^4.0.6", "@types/node": ">=4.2.0", "@types/yargs": "^13.0.0", diff --git a/packages/events-api/src/adapter.ts b/packages/events-api/src/adapter.ts index 01f678260..415edef20 100644 --- a/packages/events-api/src/adapter.ts +++ b/packages/events-api/src/adapter.ts @@ -1,10 +1,11 @@ /* tslint:disable import-name */ import EventEmitter from 'events'; -import http, { IncomingMessage, ServerResponse, RequestListener } from 'http'; +import http, { RequestListener } from 'http'; import debugFactory from 'debug'; import isString from 'lodash.isstring'; import { createHTTPHandler } from './http-handler'; import { isFalsy } from './util'; +import { RequestHandler } from 'express'; // tslint:disable-line: no-implicit-dependencies /* tslint:enable import-name */ const debug = debugFactory('@slack/events-api:adapter'); @@ -118,7 +119,7 @@ export class SlackEventAdapter extends EventEmitter { /** * Returns a middleware-compatible adapter. */ - public expressMiddleware(): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { + public expressMiddleware(): RequestHandler { const requestListener = this.requestListener(); return (req, res, _next) => { requestListener(req, res); diff --git a/packages/events-api/src/http-handler.spec.js b/packages/events-api/src/http-handler.spec.js index 1b2ca18d3..cda4b303d 100644 --- a/packages/events-api/src/http-handler.spec.js +++ b/packages/events-api/src/http-handler.spec.js @@ -1,4 +1,3 @@ -require('mocha'); const { assert } = require('chai'); const sinon = require('sinon'); const proxyquire = require('proxyquire'); diff --git a/packages/events-api/src/http-handler.ts b/packages/events-api/src/http-handler.ts index c82b6ed13..4928761b7 100644 --- a/packages/events-api/src/http-handler.ts +++ b/packages/events-api/src/http-handler.ts @@ -61,11 +61,11 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { debug('sending response - error: %s, responseOptions: %o', err, responseOptions); // Deal with errors up front if (!isFalsy(err)) { - if ('status' in err) { + if ('status' in err && typeof err.status === 'number') { res.statusCode = err.status; } else if ('code' in err && ( - err.code === ErrorCode.SignatureVerificationFailure || - err.code === ErrorCode.RequestTimeFailure + (err as CodedError).code === ErrorCode.SignatureVerificationFailure || + (err as CodedError).code === ErrorCode.RequestTimeFailure )) { res.statusCode = ResponseStatus.NotFound; } else { @@ -115,7 +115,8 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { adapter.emit('error', error, respond); } else if (process.env.NODE_ENV === 'development') { adapter.emit('error', error); - respond({ status: ResponseStatus.Failure }, { content: error.message }); + // tslint:disable-next-line: no-object-literal-type-assertion + respond({ status: ResponseStatus.Failure } as ResponseError, { content: error.message }); } else { adapter.emit('error', error); respond(error); @@ -217,14 +218,21 @@ enum ResponseStatus { type HTTPHandler = (req: IncomingMessage & { body?: any, rawBody?: Buffer }, res: ServerResponse) => void; /** - * A response handler returned by `sendResponse`. + * A Node-style response handler that takes an error (if any occurred) and a few response-related options. */ -export type ResponseHandler = (err?: Error | CodedError | { status: ResponseStatus }, responseOptions?: { +export type ResponseHandler = (err?: ResponseError, responseOptions?: { failWithNoRetry?: boolean; redirectLocation?: boolean; content?: any; }) => void; +/** + * An error (that may or may not have a status code) in response to a request. + */ +export interface ResponseError extends Error { + status?: number; +} + /** * Parameters for calling {@link verifyRequestSignature}. */ diff --git a/packages/interactive-messages/package.json b/packages/interactive-messages/package.json index 04cf1a801..8c8025674 100644 --- a/packages/interactive-messages/package.json +++ b/packages/interactive-messages/package.json @@ -56,12 +56,14 @@ "axios": "^0.18.0", "debug": "^3.1.0", "lodash.isfunction": "^3.0.9", + "lodash.isplainobject": "^4.0.6", "lodash.isregexp": "^4.0.1", "lodash.isstring": "^4.0.1", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" }, "devDependencies": { + "@types/lodash.isplainobject": "^4.0.6", "body-parser": "^1.18.2", "chai": "^4.2.0", "codecov": "^3.0.0", diff --git a/packages/interactive-messages/src/adapter.spec.js b/packages/interactive-messages/src/adapter.spec.js index 50a34a7a8..edadc63d2 100644 --- a/packages/interactive-messages/src/adapter.spec.js +++ b/packages/interactive-messages/src/adapter.spec.js @@ -397,7 +397,7 @@ describe('SlackMessageAdapter', function () { beforeEach(function () { this.adapter = new SlackMessageAdapter(workingSigningSecret, { // using a short timout to make tests finish faster - syncResponseTimeout: 30 + syncResponseTimeout: 50 }); }); diff --git a/packages/interactive-messages/src/adapter.ts b/packages/interactive-messages/src/adapter.ts index 6f4b031d8..c3d089340 100644 --- a/packages/interactive-messages/src/adapter.ts +++ b/packages/interactive-messages/src/adapter.ts @@ -4,6 +4,7 @@ import axios, { AxiosInstance } from 'axios'; import isString from 'lodash.isstring'; import isRegExp from 'lodash.isregexp'; import isFunction from 'lodash.isfunction'; +import isPlainObject from 'lodash.isplainobject'; import debugFactory from 'debug'; import { ErrorCode, errorWithCode, CodedError } from './errors'; import { createHTTPHandler } from './http-handler'; @@ -24,10 +25,10 @@ function formatMatchingConstraints(matchingConstraints } let ret: AnyConstraints = {}; - if (typeof matchingConstraints === 'string' || matchingConstraints instanceof RegExp) { - ret.callbackId = matchingConstraints; + if (!isPlainObject(matchingConstraints)) { + ret.callbackId = matchingConstraints as string | RegExp; } else { - ret = Object.assign({}, matchingConstraints); + ret = Object.assign({}, matchingConstraints as C); } return ret as C; } @@ -245,16 +246,15 @@ export class SlackMessageAdapter { callback: ActionHandler, ): this { const actionConstraints = formatMatchingConstraints(matchingConstraints); - const storableConstraints = Object.assign(actionConstraints, { - handlerType: StoredConstraintsType.Action as const, - }); - const error = validateConstraints(actionConstraints); if (error) { debug('action could not be registered: %s', error.message); throw error; } + const storableConstraints = Object.assign(actionConstraints, { + handlerType: StoredConstraintsType.Action as const, + }); return this.registerCallback(storableConstraints, callback); } @@ -280,10 +280,6 @@ export class SlackMessageAdapter { callback: OptionsHandler, ): this { const optionsConstraints = formatMatchingConstraints(matchingConstraints); - const storableConstraints = Object.assign(optionsConstraints, { - handlerType: StoredConstraintsType.Options as const, - }); - const error = validateConstraints(optionsConstraints) || validateOptionsConstraints(optionsConstraints); if (error) { @@ -291,6 +287,9 @@ export class SlackMessageAdapter { throw error; } + const storableConstraints = Object.assign(optionsConstraints, { + handlerType: StoredConstraintsType.Options as const, + }); return this.registerCallback(storableConstraints, callback); } diff --git a/packages/interactive-messages/src/http-handler.ts b/packages/interactive-messages/src/http-handler.ts index 189e8405e..6cb53496e 100644 --- a/packages/interactive-messages/src/http-handler.ts +++ b/packages/interactive-messages/src/http-handler.ts @@ -110,7 +110,7 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): RequestListener if (verifyRequestSignature(adapter.signingSecret, req.headers as VerificationHeaders, rawBody)) { // Request signature is verified // Parse raw body - const body = parseBody(rawBody) as any; + const body = parseBody(rawBody); if (body.ssl_check) { respond({ status: 200 }); From a6b22bcd6e5baecc98833642b87b6888178fa7f7 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Tue, 23 Jul 2019 10:25:23 -0700 Subject: [PATCH 22/25] Remove unneeded condition --- packages/events-api/src/http-handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/events-api/src/http-handler.ts b/packages/events-api/src/http-handler.ts index 4928761b7..a32c0f4d4 100644 --- a/packages/events-api/src/http-handler.ts +++ b/packages/events-api/src/http-handler.ts @@ -63,10 +63,10 @@ export function createHTTPHandler(adapter: SlackEventAdapter): HTTPHandler { if (!isFalsy(err)) { if ('status' in err && typeof err.status === 'number') { res.statusCode = err.status; - } else if ('code' in err && ( + } else if ( (err as CodedError).code === ErrorCode.SignatureVerificationFailure || (err as CodedError).code === ErrorCode.RequestTimeFailure - )) { + ) { res.statusCode = ResponseStatus.NotFound; } else { res.statusCode = ResponseStatus.Failure; From 3eabc3865f3713feee49ed4f4bd6f94c46fcaaea Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Tue, 23 Jul 2019 10:57:48 -0700 Subject: [PATCH 23/25] Fix documentation & typos, better respond resolved type --- packages/interactive-messages/src/adapter.ts | 10 +++++----- packages/interactive-messages/src/http-handler.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/interactive-messages/src/adapter.ts b/packages/interactive-messages/src/adapter.ts index c3d089340..4f75fab4c 100644 --- a/packages/interactive-messages/src/adapter.ts +++ b/packages/interactive-messages/src/adapter.ts @@ -592,7 +592,7 @@ const enum StoredConstraintsType { } /** - * Internal storage type that describes the constraints of an ActionHandler. + * Internal storage type that describes the constraints of an ActionHandler or OptionsHandler. */ type StoredConstraints = | ({ handlerType: StoredConstraintsType.Action } & ActionConstraints) @@ -603,11 +603,11 @@ type StoredConstraints = * up to 5 times in 30 minutes. * * @param message - a [message](https://api.slack.com/docs/interactive-message-field-guide#top-level_message_fields). - * Dialog submissions do not allow `resplace_original: false` on this message. @returnsthere's no contract or - * interface for the resolution value, but this Promise will resolve when the HTTP response from the `response_url` - * request is complete and reject when there is an error. + * Dialog submissions do not allow `replace_original: false` on this message. + * @returns there's no contract or interface for the resolution value, but this Promise will resolve when the HTTP + * response from the `response_url` request is complete and reject when there is an error. */ -type Respond = (message: any) => Promise; +type Respond = (message: any) => Promise; /** * A handler function for action requests (block actions, button presses, menu selections, diff --git a/packages/interactive-messages/src/http-handler.ts b/packages/interactive-messages/src/http-handler.ts index 6cb53496e..e18d63555 100644 --- a/packages/interactive-messages/src/http-handler.ts +++ b/packages/interactive-messages/src/http-handler.ts @@ -54,7 +54,7 @@ export function createHTTPHandler(adapter: SlackMessageAdapter): RequestListener * Method to verify signature of requests * * @param signingSecret - Signing secret used to verify request signature - * @param requestHeaders - The signing headers. If `rew` is an incoming request, then this should be `req.headers`. + * @param requestHeaders - The signing headers. If `req` is an incoming request, then this should be `req.headers`. * @param body - Raw body string * @returns Indicates if request is verified */ From 2a7c1356e135f3cc9df8e1ee9567be35d78ca0f0 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Tue, 23 Jul 2019 17:09:02 -0700 Subject: [PATCH 24/25] Fix ye olde integration type tests --- .../types/webclient-paginate-types.ts | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/integration-tests/types/webclient-paginate-types.ts b/integration-tests/types/webclient-paginate-types.ts index 0cc20369c..385b6d4a8 100644 --- a/integration-tests/types/webclient-paginate-types.ts +++ b/integration-tests/types/webclient-paginate-types.ts @@ -1,15 +1,52 @@ // tslint:disable:no-unused-expression -import { WebClient } from '@slack/web-api'; +import { WebClient, WebAPICallResult } from '@slack/web-api'; const web = new WebClient(); /* Testing the return type of WebClient#paginate() */ -// $ExpectType AsyncIterator -web.paginate('conversations.list'); +/** + * SO, for all intents and purpurposes, this thing below is just an AsyncIterator. That's what it's meant to be. "So why + * not just put `AsyncIterator`," you ask. Good question. Let me tell you a tale: + * + * Back in the year 2019, all was happy and cheerful. These integration tests $ExpectedType AsyncIterator and this + * rendundant interface was naught. Birds chirped with glee and children played in the fields. Nothing could possibly + * go wrong. + * + * Then something went wrong. From every cardinal direction a storm approached, its winds ripping trees from their roots + * and hatchlings from their mothers. Merciless, the strom tore apart the field and everything it supported. + * + * We never forget this storm. We keep its bittersweet memory in our hearts. We say its name only when it is needed: + * `typescript@3.6.0-dev.20190703`. + * + * For, you see, this was not your average storm. No, this storm approached instead as something to be celebrated, with + * its updated generator types and better iterator ergonomics. Alas, in heindsight this was but a mirage--a trojan + * horse, even--masquerading the terror that followed. + * + * It struck right at the edge: the integration tests. For, you see, their foundation is dtslint, which (at the time of + * the storm) tests against each minor release of TypeScript from 2.0 all the way to `typescript@next`. But, you see, + * this storm brought about changes in that last version. It changed the type of `AsyncIterable`, adding two new type + * arguments with defaults. Whilst usage remain unaffected, the same could not be said of our types integration tests-- + * for these tests were now failing. + * + * Our most trustworthy guard, Travis (CI), attempted to warn us of the dangerous storm, but by the time the message + * reached us the damage was done: builds were failing, PRs were reported as failing, and builds in general were a sea + * of red ✗ (read: sea of blood). + * + * This is why we've enacted this memorial: the __DangerouslyOutmodedAsyncIteratorSignatureWrapper. Its purpose is not + * only to remember the sorrows of past maintiners, but to also appease the storm by wrapping `AsyncIterator` in a new + * type that is fully equivalent, yet named different under `$ExpectType` (that is, the same across TypeScript + * versions). + */ +interface __DangerouslyOutmodedAsyncIteratorSignatureWrapper extends AsyncIterator { + // oh no +} -// $ExpectType AsyncIterator -web.paginate('conversations.list', {}); +// $ExpectType __DangerouslyOutmodedAsyncIteratorSignatureWrapper +web.paginate('conversations.list') as __DangerouslyOutmodedAsyncIteratorSignatureWrapper; + +// $ExpectType __DangerouslyOutmodedAsyncIteratorSignatureWrapper +web.paginate('conversations.list', {}) as __DangerouslyOutmodedAsyncIteratorSignatureWrapper; // $ExpectType Promise web.paginate('conversations.list', {}, () => false); From 54b9661cacda7285a0ff7a5ecd9c59efed4daac1 Mon Sep 17 00:00:00 2001 From: Calvin Watford Date: Wed, 24 Jul 2019 14:40:25 -0700 Subject: [PATCH 25/25] Level up: verbositiy, clarity, readability - Also adds chapter 2 (of 2) & footnotes --- .../types/webclient-paginate-types.ts | 55 +++++++++++++------ 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/integration-tests/types/webclient-paginate-types.ts b/integration-tests/types/webclient-paginate-types.ts index 385b6d4a8..a33f97871 100644 --- a/integration-tests/types/webclient-paginate-types.ts +++ b/integration-tests/types/webclient-paginate-types.ts @@ -6,40 +6,61 @@ const web = new WebClient(); /* Testing the return type of WebClient#paginate() */ /** - * SO, for all intents and purpurposes, this thing below is just an AsyncIterator. That's what it's meant to be. "So why + * SO. For all intents and purposes, this thing below is just an AsyncIterator. That's what it's meant to be. "So why * not just put `AsyncIterator`," you ask. Good question. Let me tell you a tale: * - * Back in the year 2019, all was happy and cheerful. These integration tests $ExpectedType AsyncIterator and this - * rendundant interface was naught. Birds chirped with glee and children played in the fields. Nothing could possibly - * go wrong. + * In the year 2019, all was happy, all was cheerful. These integration tests $ExpectedType AsyncIterator and this + * redundant interface was naught. Birds chirped with glee. Children played in the fields. Not a thing in the world + * could possibly go wrong. * - * Then something went wrong. From every cardinal direction a storm approached, its winds ripping trees from their roots - * and hatchlings from their mothers. Merciless, the strom tore apart the field and everything it supported. + * Then something went wrong. From each cardinal direction a storm approached. Its winds ripped trees from their roots + * and separated hatchlings from their mothers. Mercilessly, the storm tore apart the meadow and everything it + * supported. * - * We never forget this storm. We keep its bittersweet memory in our hearts. We say its name only when it is needed: + * This storm has not been forgotten. We keep its bittersweet memory in our hearts. Some cower at the foul beast's name: * `typescript@3.6.0-dev.20190703`. * - * For, you see, this was not your average storm. No, this storm approached instead as something to be celebrated, with - * its updated generator types and better iterator ergonomics. Alas, in heindsight this was but a mirage--a trojan - * horse, even--masquerading the terror that followed. + * For, you see, this was not your average storm. No, this storm approached instead as something to be celebrated. It + * boasted updated generator types and it advertised comfortable iterator ergonomics. Alas, in hindsight this was but a + * mirage--a trojan horse, even--masquerading the terror that followed. It knocked with its weapon upfront: no longer + * was it `AsyncIterator`, but rather `AsyncIterator`. * * It struck right at the edge: the integration tests. For, you see, their foundation is dtslint, which (at the time of - * the storm) tests against each minor release of TypeScript from 2.0 all the way to `typescript@next`. But, you see, - * this storm brought about changes in that last version. It changed the type of `AsyncIterable`, adding two new type - * arguments with defaults. Whilst usage remain unaffected, the same could not be said of our types integration tests-- - * for these tests were now failing. + * the storm) tests against each minor release of TypeScript from 2.0 all the way to `typescript@next`. Whilst usage + * remain unaffected, the same could not be said of our types integration tests. These tests now failed, for their + * single generic argument was unequal to the three dtslint expected. * * Our most trustworthy guard, Travis (CI), attempted to warn us of the dangerous storm, but by the time the message - * reached us the damage was done: builds were failing, PRs were reported as failing, and builds in general were a sea - * of red ✗ (read: sea of blood). + * reached us the damage was done. Builds were failing. PRs reported failures. Builds were a sea of red ✗'s (read: a sea + * of blood). * * This is why we've enacted this memorial: the __DangerouslyOutmodedAsyncIteratorSignatureWrapper. Its purpose is not * only to remember the sorrows of past maintiners, but to also appease the storm by wrapping `AsyncIterator` in a new * type that is fully equivalent, yet named different under `$ExpectType` (that is, the same across TypeScript * versions). + * + * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + * + * Just as there is a calm before the storm, there must also be one after. + * + * We dream of a day where dtslint only runs a specific range of supported versions. We dream of a day where dtslint can + * see the equality of an expected type without explicit defaults and the type with its defaults filled in[1]. We dream + * of a future after the storm. + * + * Once we reach that future, this memorial will have served its purpose[2]. It will be safe to remove in that future we + * dream of. + * + * ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + * + * [1]: That is, `AsyncIterator` is equal to `AsyncIterator` because the defaults cause them to + * become equal. + * [2]: This interface is no longer needed once TypeScript 3.6 or higher is the supported range, or once dtslint can + * better compare type assertions. + * + * For more information, search the history books for PR #836. */ interface __DangerouslyOutmodedAsyncIteratorSignatureWrapper extends AsyncIterator { - // oh no + // same as AsyncIterator. } // $ExpectType __DangerouslyOutmodedAsyncIteratorSignatureWrapper