diff --git a/.changeset/drop-node-18.md b/.changeset/drop-node-18.md new file mode 100644 index 000000000..6bb2a9043 --- /dev/null +++ b/.changeset/drop-node-18.md @@ -0,0 +1,5 @@ +--- +"@slack/bolt": major +--- + +Drop Node.js 18 support. The minimum required runtime is now Node.js 20 (npm >=9.6.4). diff --git a/.changeset/drop-workflow-steps.md b/.changeset/drop-workflow-steps.md new file mode 100644 index 000000000..c2d30a399 --- /dev/null +++ b/.changeset/drop-workflow-steps.md @@ -0,0 +1,5 @@ +--- +"@slack/bolt": major +--- + +Remove deprecated `WorkflowStep` class and all associated types, middleware, and utilities. Use `CustomFunction` and `app.function()` instead. diff --git a/.changeset/improve-error-handling.md b/.changeset/improve-error-handling.md new file mode 100644 index 000000000..f7e3bb2ac --- /dev/null +++ b/.changeset/improve-error-handling.md @@ -0,0 +1,5 @@ +--- +"@slack/bolt": minor +--- + +Improve error handling by leveraging `@slack/web-api` v8 error classes. Authorization errors are now properly wrapped (preserving the original error's class identity). Default error handlers log richer details for web-api errors (API error codes, rate limit durations, HTTP status codes). Re-export `SlackError`, `WebAPIPlatformError`, `WebAPIRequestError`, `WebAPIHTTPError`, and `WebAPIRateLimitedError` from the package entry point. diff --git a/.changeset/use-native-fetch.md b/.changeset/use-native-fetch.md new file mode 100644 index 000000000..19f2dc9e0 --- /dev/null +++ b/.changeset/use-native-fetch.md @@ -0,0 +1,5 @@ +--- +"@slack/bolt": major +--- + +Replace axios with native fetch for response_url calls. Remove `agent` and `clientTls` options from `AppOptions` — use `clientOptions.fetch` to provide a custom fetch implementation for proxy/TLS needs. Add `dispatcher` option to `SocketModeReceiver` for proxy/TLS configuration in socket mode. diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index b944d48c9..8065154fa 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -20,7 +20,6 @@ jobs: fail-fast: false matrix: node-version: - - 18.x - 20.x - 22.x - 24.x diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 978c42aa5..64fdb2b02 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -17,7 +17,6 @@ jobs: fail-fast: false matrix: node-version: - - 18.x - 20.x - 22.x - 24.x @@ -48,7 +47,6 @@ jobs: fail-fast: false matrix: node-version: - - 18.x - 20.x - 22.x - 24.x diff --git a/AGENTS.md b/AGENTS.md index 697b4af49..32f728f65 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,7 +113,7 @@ Listeners receive a single object with these properties (availability depends on ## Code Conventions -- **TypeScript** throughout. Compiler options in `tsconfig.json` (extends `@tsconfig/node18`, CommonJS output). +- **TypeScript** throughout. Compiler options in `tsconfig.json` (extends `@tsconfig/node20`, CommonJS output). - **Biome** for formatting and linting. Configuration in `biome.json`. - **Testing:** See the Testing section below for test frameworks and conventions. diff --git a/examples/custom-receiver/package-lock.json b/examples/custom-receiver/package-lock.json index 9b8362109..c4d6ce5c3 100644 --- a/examples/custom-receiver/package-lock.json +++ b/examples/custom-receiver/package-lock.json @@ -18,7 +18,7 @@ "koa": "^3" }, "devDependencies": { - "@tsconfig/node18": "^18.2.6", + "@tsconfig/node20": "^20.1.5", "@types/koa": "^3.0.3", "@types/node": "^24", "ts-node": "^10", @@ -341,10 +341,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@tsconfig/node18": { - "version": "18.2.6", - "resolved": "https://registry.npmjs.org/@tsconfig/node18/-/node18-18.2.6.tgz", - "integrity": "sha512-eAWQzAjPj18tKnDzmWstz4OyWewLUNBm9tdoN9LayzoboRktYx3Enk1ZXPmThj55L7c4VWYq/Bzq0A51znZfhw==", + "node_modules/@tsconfig/node20": { + "version": "20.1.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz", + "integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==", "dev": true, "license": "MIT" }, diff --git a/examples/custom-receiver/package.json b/examples/custom-receiver/package.json index c6a10bceb..9b1d040e0 100644 --- a/examples/custom-receiver/package.json +++ b/examples/custom-receiver/package.json @@ -20,7 +20,7 @@ "koa": "^3" }, "devDependencies": { - "@tsconfig/node18": "^18.2.6", + "@tsconfig/node20": "^20.1.5", "@types/koa": "^3.0.3", "@types/node": "^24", "ts-node": "^10", diff --git a/examples/custom-receiver/tsconfig.json b/examples/custom-receiver/tsconfig.json index a1d742ea0..e2506334d 100644 --- a/examples/custom-receiver/tsconfig.json +++ b/examples/custom-receiver/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node18/tsconfig.json", + "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "resolveJsonModule": true, "allowSyntheticDefaultImports": true, diff --git a/examples/getting-started-typescript/package.json b/examples/getting-started-typescript/package.json index 2306df22d..3676c1193 100644 --- a/examples/getting-started-typescript/package.json +++ b/examples/getting-started-typescript/package.json @@ -14,7 +14,7 @@ "dotenv": "^17" }, "devDependencies": { - "@tsconfig/node18": "^18.2.6", + "@tsconfig/node20": "^20.1.5", "@types/node": "^24", "ts-node": "^10", "typescript": "6.0.3" diff --git a/examples/getting-started-typescript/tsconfig.json b/examples/getting-started-typescript/tsconfig.json index a1d742ea0..e2506334d 100644 --- a/examples/getting-started-typescript/tsconfig.json +++ b/examples/getting-started-typescript/tsconfig.json @@ -1,5 +1,5 @@ { - "extends": "@tsconfig/node18/tsconfig.json", + "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "resolveJsonModule": true, "allowSyntheticDefaultImports": true, diff --git a/package-lock.json b/package-lock.json index da591bb7a..2441fbe68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,19 @@ { "name": "@slack/bolt", - "version": "4.7.3", + "version": "5.0.0-rc.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@slack/bolt", - "version": "4.7.3", + "version": "5.0.0-rc.1", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/oauth": "^3.0.5", - "@slack/socket-mode": "^2.0.7", - "@slack/types": "^2.21.1", - "@slack/web-api": "^7.16.0", - "axios": "^1.12.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/oauth": "^4.0.0-rc.1", + "@slack/socket-mode": "^3.0.0-rc.2", + "@slack/types": "^3.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "express": "^5.0.0", "path-to-regexp": "^8.1.0", "raw-body": "^3", @@ -23,10 +22,10 @@ "devDependencies": { "@biomejs/biome": "^2.4.15", "@changesets/cli": "^2.29.8", - "@tsconfig/node18": "^18.2.4", + "@tsconfig/node20": "^20.1.5", "@types/chai": "^4.1.7", "@types/mocha": "^10.0.1", - "@types/node": "18.19.130", + "@types/node": "20.19.0", "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.4", "@types/tsscmp": "^1.0.0", @@ -42,8 +41,8 @@ "typescript": "5.3.3" }, "engines": { - "node": ">=18", - "npm": ">=8.6.0" + "node": ">=20", + "npm": ">=9.6.4" }, "peerDependencies": { "@types/express": "^5.0.0" @@ -919,81 +918,83 @@ } }, "node_modules/@slack/logger": { - "version": "4.0.1", + "version": "5.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/logger/-/logger-5.0.0-rc.1.tgz", + "integrity": "sha512-3vO8zNGvk8n8tXpAzhIz1u/fHjhsLxGMhlZqzJEa3FxlXAe2lsY3qn8XBgKYEG2LmGP6ZWWmDq7vAPr2gZe2CQ==", "license": "MIT", "dependencies": { - "@types/node": ">=18" + "@types/node": ">=20" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "node_modules/@slack/oauth": { - "version": "3.0.5", + "version": "4.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/oauth/-/oauth-4.0.0-rc.1.tgz", + "integrity": "sha512-ciE79zenceNBukfWjSoqxAf335wSRDsl0xU/TdK1i9j/5XtPSx2h23OPjWt6IPOVIypmdGZiwe62iX/p2ulHsQ==", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "@types/jsonwebtoken": "^9", - "@types/node": ">=18", + "@types/node": ">=20", "jsonwebtoken": "^9" }, "engines": { - "node": ">=18", - "npm": ">=8.6.0" + "node": ">=20", + "npm": ">=9.6.4" } }, "node_modules/@slack/socket-mode": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-2.0.7.tgz", - "integrity": "sha512-qYy07je71WnEHgRwmw12DlAnZLi5HXmdlI2WUzUK2LH/rYXQpP6uEg462S5CwfE8FoCKUdIigHtYnOOfzZH1lQ==", + "version": "3.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@slack/socket-mode/-/socket-mode-3.0.0-rc.2.tgz", + "integrity": "sha512-otuxvm+fRdaUeJHxfQla4iDULbBRiKQrXjagSBFxJkjCpYCOPCdkIorc6LiM/GO9i7RWYRQZKzOj8fNv92PKyg==", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/web-api": "^7.15.0", - "@types/node": ">=18", - "@types/ws": "^8", - "eventemitter3": "^5", - "ws": "^8" + "@slack/logger": "^5.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", + "@types/node": ">=20", + "eventemitter3": "^5" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">=20", + "npm": ">=9.6.4" + }, + "peerDependencies": { + "undici": "^7.0.0" } }, "node_modules/@slack/types": { - "version": "2.21.1", - "resolved": "https://registry.npmjs.org/@slack/types/-/types-2.21.1.tgz", - "integrity": "sha512-I8vmSjNYWsaxuWPx6dz4yeh0h7vRBWbgAMK14LEmblbZ404BtrPbXs6jDPx4cYgGf8msDGF4A9opLZBu21FViQ==", + "version": "3.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/types/-/types-3.0.0-rc.1.tgz", + "integrity": "sha512-xJZm26o5YK95OdM8BliTE+LijNhMGAwLWWpSpfnrPno4DyLBthNAjC7SG/9Ow2gB7oXCeYadpF/nzlYz7XaATg==", "license": "MIT", "engines": { - "node": ">= 12.13.0", - "npm": ">= 6.12.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "node_modules/@slack/web-api": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.16.0.tgz", - "integrity": "sha512-68SAV77uuGKuhyyaRytX8UijVnqSLsTSKslGXw17cjQYXn+jtNl7gbaEjHgC5x2rhCuFdahBrEC2VCLppbzReg==", + "version": "8.0.0-rc.1", + "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-8.0.0-rc.1.tgz", + "integrity": "sha512-ZFJCYoAq0kC4pUe3V41YUOVvG/mceZ1yCcFMRCkYNu5joEuzkhKQpU4XfD2LnkJenWKwW6mg9J5KbBDMRCxCjg==", "license": "MIT", "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/types": "^2.21.0", - "@types/node": ">=18", + "@slack/logger": "^5.0.0-rc.1", + "@slack/types": "^3.0.0-rc.1", + "@types/node": ">=20", "@types/retry": "0.12.0", "axios": "^1.16.0", "eventemitter3": "^5.0.1", - "form-data": "^4.0.4", - "is-electron": "2.2.2", - "is-stream": "^2", "p-queue": "^6", "p-retry": "^4", "retry": "^0.13.1" }, "engines": { - "node": ">= 18", - "npm": ">= 8.6.0" + "node": ">= 20", + "npm": ">=9.6.4" } }, "node_modules/@tsconfig/node10": { @@ -1016,8 +1017,10 @@ "dev": true, "license": "MIT" }, - "node_modules/@tsconfig/node18": { - "version": "18.2.6", + "node_modules/@tsconfig/node20": { + "version": "20.1.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node20/-/node20-20.1.9.tgz", + "integrity": "sha512-IjlTv1RsvnPtUcjTqtVsZExKVq+KQx4g5pCP5tI7rAs6Xesl2qFwSz/tPDBC4JajkL/MlezBu3gPUwqRHl+RIg==", "dev": true, "license": "MIT" }, @@ -1124,12 +1127,20 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.19.130", + "version": "20.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.0.tgz", + "integrity": "sha512-hfrc+1tud1xcdVTABC2JiomZJEklMcXYNTVtZLAeqTVWD+qL5jkHKT+1lOtqDdGxt+mB53DTtiz673vfjU8D1Q==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.21.0" } }, + "node_modules/@types/node/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "dev": true, @@ -1189,13 +1200,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/ws": { - "version": "8.18.1", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/accepts": { "version": "2.0.0", "license": "MIT", @@ -1619,6 +1623,8 @@ }, "node_modules/combined-stream": { "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -1746,6 +1752,8 @@ }, "node_modules/delayed-stream": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", "license": "MIT", "engines": { "node": ">=0.4.0" @@ -1879,6 +1887,8 @@ }, "node_modules/es-set-tostringtag": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -2141,6 +2151,8 @@ }, "node_modules/form-data": { "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -2155,6 +2167,8 @@ }, "node_modules/form-data/node_modules/mime-db": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2162,6 +2176,8 @@ }, "node_modules/form-data/node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -2361,6 +2377,8 @@ }, "node_modules/has-tostringtag": { "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -2541,10 +2559,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-electron": { - "version": "2.2.2", - "license": "MIT" - }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -2600,16 +2614,6 @@ "version": "4.0.0", "license": "MIT" }, - "node_modules/is-stream": { - "version": "2.0.1", - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-subdir": { "version": "1.2.0", "dev": true, @@ -3522,6 +3526,8 @@ }, "node_modules/proxy-from-env": { "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "license": "MIT", "engines": { "node": ">=10" @@ -4594,9 +4600,15 @@ "node": ">=14.17" } }, - "node_modules/undici-types": { - "version": "5.26.5", - "license": "MIT" + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.18.1" + } }, "node_modules/universalify": { "version": "0.1.2", @@ -4712,25 +4724,6 @@ "version": "1.0.2", "license": "ISC" }, - "node_modules/ws": { - "version": "8.20.0", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/y18n": { "version": "5.0.8", "dev": true, diff --git a/package.json b/package.json index ecceed50c..d1851d8cb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slack/bolt", - "version": "4.7.3", + "version": "5.0.0-rc.1", "description": "A framework for building Slack apps, fast.", "author": "Slack Technologies, LLC", "license": "MIT", @@ -21,8 +21,8 @@ "dist/**/*" ], "engines": { - "node": ">=18", - "npm": ">=8.6.0" + "node": ">=20", + "npm": ">=9.6.4" }, "scripts": { "build": "npm run build:clean && tsc", @@ -47,12 +47,11 @@ "url": "https://github.com/slackapi/bolt-js/issues" }, "dependencies": { - "@slack/logger": "^4.0.1", - "@slack/oauth": "^3.0.5", - "@slack/socket-mode": "^2.0.7", - "@slack/types": "^2.21.1", - "@slack/web-api": "^7.16.0", - "axios": "^1.12.0", + "@slack/logger": "^5.0.0-rc.1", + "@slack/oauth": "^4.0.0-rc.1", + "@slack/socket-mode": "^3.0.0-rc.2", + "@slack/types": "^3.0.0-rc.1", + "@slack/web-api": "^8.0.0-rc.1", "express": "^5.0.0", "path-to-regexp": "^8.1.0", "raw-body": "^3", @@ -61,10 +60,10 @@ "devDependencies": { "@biomejs/biome": "^2.4.15", "@changesets/cli": "^2.29.8", - "@tsconfig/node18": "^18.2.4", + "@tsconfig/node20": "^20.1.5", "@types/chai": "^4.1.7", "@types/mocha": "^10.0.1", - "@types/node": "18.19.130", + "@types/node": "20.19.0", "@types/proxyquire": "^1.3.31", "@types/sinon": "^17.0.4", "@types/tsscmp": "^1.0.0", diff --git a/src/App.ts b/src/App.ts index 8a7a91616..ac696b72a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,9 +1,14 @@ -import type { Agent } from 'node:http'; -import type { SecureContextOptions } from 'node:tls'; import util from 'node:util'; import { ConsoleLogger, type Logger, LogLevel } from '@slack/logger'; -import { addAppMetadata, WebClient, type WebClientOptions } from '@slack/web-api'; -import axios, { type AxiosInstance } from 'axios'; +import { + addAppMetadata, + type FetchFunction, + WebAPIHTTPError, + WebAPIPlatformError, + WebAPIRateLimitedError, + WebClient, + type WebClientOptions, +} from '@slack/web-api'; import type { Assistant } from './Assistant'; import { CustomFunction, @@ -24,9 +29,9 @@ import { import { type ConversationStore, conversationContext, MemoryStore } from './conversation-store'; import { AppInitializationError, + AuthorizationError, asCodedError, type CodedError, - ErrorCode, InvalidCustomPropertyError, MultipleListenerError, } from './errors'; @@ -93,11 +98,9 @@ import type { SlashCommand, ViewConstraints, ViewOutput, - WorkflowStepEdit, } from './types'; import { contextBuiltinKeys } from './types'; import { isRejected, type StringIndexed } from './types/utilities'; -import type { WorkflowStep } from './WorkflowStep'; const packageJson = require('../package.json'); @@ -130,8 +133,6 @@ export interface AppOptions { installationStore?: HTTPReceiverOptions['installationStore']; // default MemoryInstallationStore scopes?: HTTPReceiverOptions['scopes']; installerOptions?: HTTPReceiverOptions['installerOptions']; - agent?: Agent; - clientTls?: Pick; convoStore?: ConversationStore | false; token?: AuthorizeResult['botToken']; // either token or authorize appToken?: string; // TODO should this be included in AuthorizeResult @@ -258,7 +259,7 @@ export default class App private errorHandler: AnyErrorHandler; - private axios: AxiosInstance; + private fetchFn: FetchFunction; private installerOptions: HTTPReceiverOptions['installerOptions']; @@ -290,8 +291,6 @@ export default class App endpoints = undefined, port = undefined, customRoutes = undefined, - agent = undefined, - clientTls = undefined, receiver = undefined, convoStore = undefined, token = undefined, @@ -355,12 +354,6 @@ export default class App /* ------------------------ Set client options ------------------------*/ this.clientOptions = clientOptions !== undefined ? clientOptions : {}; - if (agent !== undefined && this.clientOptions.agent === undefined) { - this.clientOptions.agent = agent; - } - if (clientTls !== undefined && this.clientOptions.tls === undefined) { - this.clientOptions.tls = clientTls; - } if (logLevel !== undefined && logger === undefined) { // only logLevel is passed this.clientOptions.logLevel = logLevel; @@ -372,16 +365,7 @@ export default class App // Since v3.4, it can have the passed token in the case of single workspace installation. this.client = new WebClient(token, this.clientOptions); - this.axios = axios.create({ - httpAgent: agent, - httpsAgent: agent, - // disabling axios' automatic proxy support: - // axios would read from env vars to configure a proxy automatically, but it doesn't support TLS destinations. - // for compatibility with https://api.slack.com, and for a larger set of possible proxies (SOCKS or other - // protocols), users of this package should use the `agent` option to configure a proxy. - proxy: false, - ...clientTls, - }); + this.fetchFn = this.clientOptions.fetch ?? globalThis.fetch; this.middleware = []; this.listeners = []; @@ -533,19 +517,6 @@ export default class App return this; } - /** - * Register WorkflowStep middleware - * - * @param workflowStep global workflow step middleware function - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ - public step(workflowStep: WorkflowStep): this { - const m = workflowStep.getMiddleware(); - this.middleware.push(m); - return this; - } - /** * Register middleware for a workflow step. * @param callbackId Unique callback ID of a step. @@ -957,12 +928,11 @@ export default class App try { authorizeResult = await this.authorize(source, bodyArg); } catch (error) { - // biome-ignore lint/suspicious/noExplicitAny: errors can be anything - const e = error as any; + const e = error instanceof Error ? error : new Error(String(error)); this.logger.warn('Authorization of incoming event did not succeed. No listeners will be called.'); - e.code = ErrorCode.AuthorizationError; + const authError = new AuthorizationError(`Authorization of incoming event did not succeed. ${e.message}`, e); await this.handleError({ - error: e, + error: authError, logger: this.logger, body: bodyArg, context: { @@ -1019,11 +989,9 @@ export default class App // Set body and payload // TODO: this value should eventually conform to AnyMiddlewareArgs - // TODO: remove workflow step stuff in bolt v5 // TODO: can we instead use type predicates in these switch cases to allow for narrowing of the body simultaneously? we have isEvent, isView, isShortcut, isAction already in types/utilities / helpers let payload: | DialogSubmitAction - | WorkflowStepEdit | SlackShortcut | KnownEventFromType | SlashCommand @@ -1166,10 +1134,10 @@ export default class App // Set respond() utility if (body.response_url) { - listenerArgs.respond = createRespond(this.axios, body.response_url); + listenerArgs.respond = createRespond(this.fetchFn, body.response_url); } else if (typeof body.response_urls !== 'undefined' && body.response_urls.length > 0) { // This can exist only when view_submission payloads - response_url_enabled: true - listenerArgs.respond = createRespond(this.axios, body.response_urls[0].response_url); + listenerArgs.respond = createRespond(this.fetchFn, body.response_urls[0].response_url); } // Set ack() utility @@ -1391,7 +1359,15 @@ export default class App function defaultErrorHandler(logger: Logger): ErrorHandler { return (error: CodedError) => { - logger.error(error); + if (error instanceof WebAPIPlatformError) { + logger.error(`Slack API error: ${error.data.error}`); + } else if (error instanceof WebAPIRateLimitedError) { + logger.error(`Rate limited, retry after ${error.retryAfter}s`); + } else if (error instanceof WebAPIHTTPError) { + logger.error(`HTTP error ${error.statusCode}: ${error.statusMessage}`); + } else { + logger.error(error); + } return Promise.reject(error); }; diff --git a/src/WorkflowStep.ts b/src/WorkflowStep.ts deleted file mode 100644 index 84f941517..000000000 --- a/src/WorkflowStep.ts +++ /dev/null @@ -1,432 +0,0 @@ -import type { WorkflowStepExecuteEvent } from '@slack/types'; -import type { - Block, - KnownBlock, - ViewsOpenResponse, - WorkflowsStepCompletedResponse, - WorkflowsStepFailedResponse, - WorkflowsUpdateStepResponse, -} from '@slack/web-api'; -import { WorkflowStepInitializationError } from './errors'; -import processMiddleware from './middleware/process'; -import type { - AllMiddlewareArgs, - AnyMiddlewareArgs, - Context, - Middleware, - SlackActionMiddlewareArgs, - SlackEventMiddlewareArgs, - SlackViewMiddlewareArgs, - ViewWorkflowStepSubmitAction, - WorkflowStepEdit, -} from './types'; - -/** Interfaces */ - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface StepConfigureArguments { - blocks: (KnownBlock | Block)[]; - private_metadata?: string; - submit_disabled?: boolean; - external_id?: string; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface StepUpdateArguments { - inputs?: Record< - string, - { - // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow inputs could be anything - value: any; - skip_variable_replacement?: boolean; - // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow inputs could be anything - variables?: Record; - } - >; - outputs?: { - name: string; - type: string; - label: string; - }[]; - step_name?: string; - step_image_url?: string; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface StepCompleteArguments { - // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow outputs could be anything - outputs?: Record; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface StepFailArguments { - error: { - message: string; - }; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type StepConfigureFn = (params: StepConfigureArguments) => Promise; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type StepUpdateFn = (params?: StepUpdateArguments) => Promise; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type StepCompleteFn = (params?: StepCompleteArguments) => Promise; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type StepFailFn = (params: StepFailArguments) => Promise; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface WorkflowStepConfig { - edit: WorkflowStepEditMiddleware | WorkflowStepEditMiddleware[]; - save: WorkflowStepSaveMiddleware | WorkflowStepSaveMiddleware[]; - execute: WorkflowStepExecuteMiddleware | WorkflowStepExecuteMiddleware[]; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface WorkflowStepEditMiddlewareArgs extends SlackActionMiddlewareArgs { - step: WorkflowStepEdit['workflow_step']; - configure: StepConfigureFn; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface WorkflowStepSaveMiddlewareArgs extends SlackViewMiddlewareArgs { - step: ViewWorkflowStepSubmitAction['workflow_step']; - update: StepUpdateFn; -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface WorkflowStepExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'workflow_step_execute'> { - step: WorkflowStepExecuteEvent['workflow_step']; - complete: StepCompleteFn; - fail: StepFailFn; -} - -/** Types */ - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type SlackWorkflowStepMiddlewareArgs = - | WorkflowStepEditMiddlewareArgs - | WorkflowStepSaveMiddlewareArgs - | WorkflowStepExecuteMiddlewareArgs; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type WorkflowStepEditMiddleware = Middleware; -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type WorkflowStepSaveMiddleware = Middleware; -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type WorkflowStepExecuteMiddleware = Middleware; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type WorkflowStepMiddleware = - | WorkflowStepEditMiddleware[] - | WorkflowStepSaveMiddleware[] - | WorkflowStepExecuteMiddleware[]; - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export type AllWorkflowStepMiddlewareArgs = - T & AllMiddlewareArgs; - -/** Constants */ - -const VALID_PAYLOAD_TYPES = new Set(['workflow_step_edit', 'workflow_step', 'workflow_step_execute']); - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export class WorkflowStep { - /** Step callback_id */ - private callbackId: string; - - /** Step Add/Edit :: 'workflow_step_edit' action */ - private edit: WorkflowStepEditMiddleware[]; - - /** Step Config Save :: 'view_submission' */ - private save: WorkflowStepSaveMiddleware[]; - - /** Step Executed/Run :: 'workflow_step_execute' event */ - private execute: WorkflowStepExecuteMiddleware[]; - - public constructor(callbackId: string, config: WorkflowStepConfig) { - validate(callbackId, config); - - const { save, edit, execute } = config; - - this.callbackId = callbackId; - this.save = Array.isArray(save) ? save : [save]; - this.edit = Array.isArray(edit) ? edit : [edit]; - this.execute = Array.isArray(execute) ? execute : [execute]; - } - - public getMiddleware(): Middleware { - return async (args): Promise => { - if (isStepEvent(args) && this.matchesConstraints(args)) { - return this.processEvent(args); - } - return args.next(); - }; - } - - private matchesConstraints(args: SlackWorkflowStepMiddlewareArgs): boolean { - return args.payload.callback_id === this.callbackId; - } - - private async processEvent(args: AllWorkflowStepMiddlewareArgs): Promise { - const { payload } = args; - const stepArgs = prepareStepArgs(args); - const stepMiddleware = this.getStepMiddleware(payload); - return processStepMiddleware(stepArgs, stepMiddleware); - } - - private getStepMiddleware(payload: AllWorkflowStepMiddlewareArgs['payload']): WorkflowStepMiddleware { - switch (payload.type) { - case 'workflow_step_edit': - return this.edit; - case 'workflow_step': - return this.save; - case 'workflow_step_execute': - return this.execute; - default: - return []; - } - } -} - -/** Helper Functions */ - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export function validate(callbackId: string, config: WorkflowStepConfig): void { - // Ensure callbackId is valid - if (typeof callbackId !== 'string') { - const errorMsg = 'WorkflowStep expects a callback_id as the first argument'; - throw new WorkflowStepInitializationError(errorMsg); - } - - // Ensure step config object is passed in - if (typeof config !== 'object') { - const errorMsg = 'WorkflowStep expects a configuration object as the second argument'; - throw new WorkflowStepInitializationError(errorMsg); - } - - // Check for missing required keys - const requiredKeys: (keyof WorkflowStepConfig)[] = ['save', 'edit', 'execute']; - const missingKeys: (keyof WorkflowStepConfig)[] = []; - for (const key of requiredKeys) { - if (config[key] === undefined) { - missingKeys.push(key); - } - } - - if (missingKeys.length > 0) { - const errorMsg = `WorkflowStep is missing required keys: ${missingKeys.join(', ')}`; - throw new WorkflowStepInitializationError(errorMsg); - } - - // Ensure a callback or an array of callbacks is present - const requiredFns: (keyof WorkflowStepConfig)[] = ['save', 'edit', 'execute']; - for (const fn of requiredFns) { - if (typeof config[fn] !== 'function' && !Array.isArray(config[fn])) { - const errorMsg = `WorkflowStep ${fn} property must be a function or an array of functions`; - throw new WorkflowStepInitializationError(errorMsg); - } - } -} - -/** - * `processStepMiddleware()` invokes each callback for lifecycle event - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - * @param args workflow_step_edit action - */ -export async function processStepMiddleware( - args: AllWorkflowStepMiddlewareArgs, - middleware: WorkflowStepMiddleware, -): Promise { - const { context, client, logger } = args; - // TODO :: revisit type used below (look into contravariance) - const callbacks = [...middleware] as Middleware[]; - const lastCallback = callbacks.pop(); - - if (lastCallback !== undefined) { - await processMiddleware(callbacks, args, context, client, logger, async () => - lastCallback({ ...args, context, client, logger }), - ); - } -} - -/** @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export function isStepEvent(args: AnyMiddlewareArgs): args is AllWorkflowStepMiddlewareArgs { - return VALID_PAYLOAD_TYPES.has(args.payload.type); -} - -function selectToken(context: Context): string | undefined { - return context.botToken !== undefined ? context.botToken : context.userToken; -} - -/** - * Factory for `configure()` utility - * @param args workflow_step_edit action - */ -function createStepConfigure(args: AllWorkflowStepMiddlewareArgs): StepConfigureFn { - const { - context, - client, - body: { callback_id, trigger_id }, - } = args; - const token = selectToken(context); - - return (params: Parameters[0]) => - client.views.open({ - token, - trigger_id, - view: { - callback_id, - type: 'workflow_step', - ...params, - }, - }); -} - -/** - * Factory for `update()` utility - * @param args view_submission event - */ -function createStepUpdate(args: AllWorkflowStepMiddlewareArgs): StepUpdateFn { - const { - context, - client, - body: { - workflow_step: { workflow_step_edit_id }, - }, - } = args; - const token = selectToken(context); - - return (params: Parameters[0] = {}) => - client.workflows.updateStep({ - token, - workflow_step_edit_id, - ...params, - }); -} - -/** - * Factory for `complete()` utility - * @param args workflow_step_execute event - */ -function createStepComplete(args: AllWorkflowStepMiddlewareArgs): StepCompleteFn { - const { - context, - client, - payload: { - workflow_step: { workflow_step_execute_id }, - }, - } = args; - const token = selectToken(context); - - return (params: Parameters[0] = {}) => - client.workflows.stepCompleted({ - token, - workflow_step_execute_id, - ...params, - }); -} - -/** - * Factory for `fail()` utility - * @param args workflow_step_execute event - */ -function createStepFail(args: AllWorkflowStepMiddlewareArgs): StepFailFn { - const { - context, - client, - payload: { - workflow_step: { workflow_step_execute_id }, - }, - } = args; - const token = selectToken(context); - - return (params: Parameters[0]) => { - const { error } = params; - return client.workflows.stepFailed({ - token, - workflow_step_execute_id, - error, - }); - }; -} - -/** - * `prepareStepArgs()` takes in a step's args and: - * 1. removes the next() passed in from App-level middleware processing - * - events will *not* continue down global middleware chain to subsequent listeners - * 2. augments args with step lifecycle-specific properties/utilities - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -// TODO :: refactor to incorporate a generic parameter -export function prepareStepArgs(args: AllWorkflowStepMiddlewareArgs): AllWorkflowStepMiddlewareArgs { - const { next: _next, ...stepArgs } = args; - // biome-ignore lint/suspicious/noExplicitAny: need to use any as the cases of the switch that follows dont narrow to the specific required args type. use type predicates for each workflow_step event args in the switch to get rid of this any. - const preparedArgs: any = { ...stepArgs }; - - switch (preparedArgs.payload.type) { - case 'workflow_step_edit': - preparedArgs.step = preparedArgs.action.workflow_step; - preparedArgs.configure = createStepConfigure(preparedArgs); - break; - case 'workflow_step': - preparedArgs.step = preparedArgs.body.workflow_step; - preparedArgs.update = createStepUpdate(preparedArgs); - break; - case 'workflow_step_execute': - preparedArgs.step = preparedArgs.event.workflow_step; - preparedArgs.complete = createStepComplete(preparedArgs); - preparedArgs.fail = createStepFail(preparedArgs); - break; - default: - break; - } - - return preparedArgs; -} diff --git a/src/context/create-respond.ts b/src/context/create-respond.ts index 8daf71987..ca2a81b87 100644 --- a/src/context/create-respond.ts +++ b/src/context/create-respond.ts @@ -1,12 +1,13 @@ -import type { AxiosInstance, AxiosResponse } from 'axios'; -import type { RespondArguments } from '../types'; +import type { FetchFunction } from '@slack/web-api'; +import type { RespondArguments, RespondFn } from '../types'; -export function createRespond( - axiosInstance: AxiosInstance, - responseUrl: string, -): (response: string | RespondArguments) => Promise { +export function createRespond(fetchFn: FetchFunction, responseUrl: string): RespondFn { return async (message: string | RespondArguments) => { const normalizedArgs: RespondArguments = typeof message === 'string' ? { text: message } : message; - return axiosInstance.post(responseUrl, normalizedArgs); + return fetchFn(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(normalizedArgs), + }); }; } diff --git a/src/errors.ts b/src/errors.ts index ef250b44c..40b1be79e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -42,9 +42,6 @@ export enum ErrorCode { */ UnknownError = 'slack_bolt_unknown_error', - // TODO: remove workflow step stuff in bolt v5 - WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', - CustomFunctionInitializationError = 'slack_bolt_custom_function_initialization_error', CustomFunctionCompleteSuccessError = 'slack_bolt_custom_function_complete_success_error', CustomFunctionCompleteFailError = 'slack_bolt_custom_function_complete_fail_error', @@ -156,14 +153,6 @@ export class MultipleListenerError extends Error implements CodedError { this.originals = originals; } } -/** - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export class WorkflowStepInitializationError extends Error implements CodedError { - public code = ErrorCode.WorkflowStepInitializationError; -} - export class CustomFunctionInitializationError extends Error implements CodedError { public code = ErrorCode.CustomFunctionInitializationError; } diff --git a/src/helpers.ts b/src/helpers.ts index db6417c7a..a0ad1ca89 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -82,8 +82,7 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType; c conversationId: optionsBody.channel !== undefined ? optionsBody.channel.id : undefined, }; } - // TODO: remove workflow_step stuff in v5 - if (body.actions !== undefined || body.type === 'dialog_submission' || body.type === 'workflow_step_edit') { + if (body.actions !== undefined || body.type === 'dialog_submission') { const actionBody = body as SlackActionMiddlewareArgs['body']; return { type: IncomingEventType.Action, diff --git a/src/index.ts b/src/index.ts index 52f1693dd..31a7c15cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,11 +75,4 @@ export { } from './receivers/SocketModeFunctions'; export type { SocketModeReceiverOptions } from './receivers/SocketModeReceiver'; export * from './types'; -export { - WorkflowStep, - WorkflowStepConfig, - WorkflowStepEditMiddleware, - WorkflowStepExecuteMiddleware, - WorkflowStepSaveMiddleware, -} from './WorkflowStep'; export { AwsLambdaReceiver, ExpressReceiver, HTTPReceiver, SocketModeReceiver }; diff --git a/src/receivers/HTTPModuleFunctions.ts b/src/receivers/HTTPModuleFunctions.ts index 7091b71fd..239bb7c67 100644 --- a/src/receivers/HTTPModuleFunctions.ts +++ b/src/receivers/HTTPModuleFunctions.ts @@ -2,7 +2,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { parse as qsParse } from 'node:querystring'; import type { Logger } from '@slack/logger'; import rawBody from 'raw-body'; -import { type CodedError, ErrorCode } from '../errors'; +import { AuthorizationError, type CodedError, HTTPReceiverDeferredRequestError } from '../errors'; import type { BufferedIncomingMessage } from './BufferedIncomingMessage'; import { verifySlackRequest } from './verify-request'; @@ -166,13 +166,11 @@ export const buildContentResponse = (res: ServerResponse, body: any): void => { // Note that it was not possible to make this function async due to the limitation of http module export const defaultDispatchErrorHandler = (args: ReceiverDispatchErrorHandlerArgs): void => { const { error, logger, request, response } = args; - if ('code' in error) { - if (error.code === ErrorCode.HTTPReceiverDeferredRequestError) { - logger.info(`Unhandled HTTP request (${request.method}) made to ${request.url}`); - response.writeHead(404); - response.end(); - return; - } + if (error instanceof HTTPReceiverDeferredRequestError) { + logger.info(`Unhandled HTTP request (${request.method}) made to ${request.url}`); + response.writeHead(404); + response.end(); + return; } logger.error(`An unexpected error occurred during a request (${request.method}) made to ${request.url}`); logger.debug(`Error details: ${error}`); @@ -196,13 +194,11 @@ export const defaultProcessEventErrorHandler = async (args: ReceiverProcessEvent return false; } - if ('code' in error) { - if (error.code === ErrorCode.AuthorizationError) { - // authorize function threw an exception, which means there is no valid installation data - response.writeHead(401); - response.end(); - return true; - } + if (error instanceof AuthorizationError) { + // authorize function threw an exception, which means there is no valid installation data + response.writeHead(401); + response.end(); + return true; } logger.error('An unhandled error occurred while Bolt processed an event'); logger.debug(`Error details: ${error}, storedResponse: ${storedResponse}`); diff --git a/src/receivers/SocketModeFunctions.ts b/src/receivers/SocketModeFunctions.ts index ea8fc16fe..69b8043a1 100644 --- a/src/receivers/SocketModeFunctions.ts +++ b/src/receivers/SocketModeFunctions.ts @@ -1,5 +1,5 @@ import type { Logger } from '@slack/logger'; -import { type CodedError, ErrorCode, isCodedError } from '../errors'; +import { AuthorizationError, type CodedError } from '../errors'; import type { ReceiverEvent } from '../types'; export async function defaultProcessEventErrorHandler( @@ -11,7 +11,7 @@ export async function defaultProcessEventErrorHandler( // to return more properties to 'slack_event' listeners logger.error(`An unhandled error occurred while Bolt processed (type: ${event.body?.type}, error: ${error})`); logger.debug(`Error details: ${error}, retry num: ${event.retryNum}, retry reason: ${event.retryReason}`); - if (isCodedError(error) && error.code === ErrorCode.AuthorizationError) { + if (error instanceof AuthorizationError) { // The `authorize` function threw an exception, which means there is no valid installation data. // In this case, we can tell the Slack server-side to stop retries. return true; diff --git a/src/receivers/SocketModeReceiver.ts b/src/receivers/SocketModeReceiver.ts index 0d22078c0..9d3c4e2ea 100644 --- a/src/receivers/SocketModeReceiver.ts +++ b/src/receivers/SocketModeReceiver.ts @@ -8,6 +8,7 @@ import { type InstallProviderOptions, type InstallURLOptions, } from '@slack/oauth'; +import type { SocketModeOptions } from '@slack/socket-mode'; import { SocketModeClient } from '@slack/socket-mode'; import type { AppsConnectionsOpenResponse } from '@slack/web-api'; import type { ParamsDictionary } from 'express-serve-static-core'; @@ -38,6 +39,7 @@ export interface SocketModeReceiverOptions { scopes?: InstallURLOptions['scopes']; installerOptions?: InstallerOptions; appToken: string; // App Level Token + dispatcher?: SocketModeOptions['dispatcher']; customRoutes?: CustomRoute[]; clientPingTimeout?: number; serverPingTimeout?: number; @@ -98,6 +100,7 @@ export default class SocketModeReceiver implements Receiver { public constructor({ appToken, + dispatcher, logger = undefined, logLevel = LogLevel.INFO, clientPingTimeout = undefined, @@ -117,6 +120,7 @@ export default class SocketModeReceiver implements Receiver { }: SocketModeReceiverOptions) { this.client = new SocketModeClient({ appToken, + dispatcher, logLevel, logger, clientPingTimeout, diff --git a/src/types/actions/index.ts b/src/types/actions/index.ts index badfc13e1..7a635b5a0 100644 --- a/src/types/actions/index.ts +++ b/src/types/actions/index.ts @@ -4,13 +4,10 @@ import type { AckFn, RespondFn, SayArguments, SayFn } from '../utilities'; import type { BlockAction } from './block-action'; import type { DialogSubmitAction, DialogValidation } from './dialog-action'; import type { InteractiveMessage } from './interactive-message'; -import type { WorkflowStepEdit } from './workflow-step-edit'; export * from './block-action'; export * from './dialog-action'; export * from './interactive-message'; -// TODO: remove workflow step stuff in bolt v5 -export * from './workflow-step-edit'; /** * All known actions from Slack's Block Kit interactive components, message actions, dialogs, and legacy interactive @@ -26,8 +23,7 @@ export * from './workflow-step-edit'; * offered when no generic parameter is bound would be limited to BasicElementAction rather than the union of known * actions - ElementAction. */ -// TODO: remove workflow step stuff in bolt v5 -export type SlackAction = BlockAction | InteractiveMessage | DialogSubmitAction | WorkflowStepEdit; +export type SlackAction = BlockAction | InteractiveMessage | DialogSubmitAction; export interface ActionConstraints { type?: A['type']; @@ -66,9 +62,8 @@ export type SlackActionMiddlewareArgs complete?: FunctionCompleteFn; fail?: FunctionFailFn; inputs?: FunctionInputs; - // TODO: remove workflow step stuff in bolt v5 -} & (Action extends Exclude - ? // all action types except dialog submission and steps from apps have a channel context +} & (Action extends Exclude + ? // all action types except dialog submission have a channel context // TODO: not exactly true: a block action could occur from a view. should improve this. { say: SayFn } : unknown); diff --git a/src/types/actions/workflow-step-edit.ts b/src/types/actions/workflow-step-edit.ts deleted file mode 100644 index aa3c37bcd..000000000 --- a/src/types/actions/workflow-step-edit.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * A Slack step from app action wrapped in the standard metadata. - * - * This describes the entire JSON-encoded body of a request from Slack step from app actions. - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface WorkflowStepEdit { - type: 'workflow_step_edit'; - callback_id: string; - trigger_id: string; - user: { - id: string; - username: string; - team_id?: string; // undocumented - }; - team: { - id: string; - domain: string; - enterprise_id?: string; // undocumented - enterprise_name?: string; // undocumented - }; - channel?: { - id?: string; - name?: string; - }; - token: string; - action_ts: string; // undocumented - workflow_step: { - workflow_id: string; - step_id: string; - inputs: Record< - string, - { - // biome-ignore lint/suspicious/noExplicitAny: input parameters can accept anything - value: any; - } - >; - outputs: { - name: string; - type: string; - label: string; - }[]; - step_name?: string; - step_image_url?: string; - }; - - // exists for enterprise installs - is_enterprise_install?: boolean; - enterprise?: { - id: string; - name: string; - }; -} diff --git a/src/types/view/index.ts b/src/types/view/index.ts index 585f0918c..ac5f1c87d 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -5,11 +5,7 @@ import type { AckFn, RespondFn } from '../utilities'; /** * Known view action types */ -export type SlackViewAction = - | ViewSubmitAction - | ViewClosedAction - | ViewWorkflowStepSubmitAction // TODO: remove workflow step stuff in bolt v5 - | ViewWorkflowStepClosedAction; +export type SlackViewAction = ViewSubmitAction | ViewClosedAction; // // TODO: add a type parameter here, just like the other constraint interfaces have. export interface ViewConstraints { @@ -104,38 +100,6 @@ export interface ViewClosedAction { }; } -/** - * A Slack view_submission step from app event - * - * This describes the additional JSON-encoded body details for a step's view_submission event - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface ViewWorkflowStepSubmitAction extends ViewSubmitAction { - trigger_id: string; - response_urls?: ViewResponseUrl[]; - workflow_step: { - workflow_step_edit_id: string; - workflow_id: string; - step_id: string; - }; -} - -/** - * A Slack view_closed step from app event - * - * This describes the additional JSON-encoded body details for a step's view_closed event - * @deprecated Steps from Apps are no longer supported and support for them will be removed in the next major bolt-js - * version. - */ -export interface ViewWorkflowStepClosedAction extends ViewClosedAction { - workflow_step: { - workflow_step_edit_id: string; - workflow_id: string; - step_id: string; - }; -} - export interface ViewStateSelectedOption { text: PlainTextElement; value: string; diff --git a/test/types/App.test-d.ts b/test/types/App.test-d.ts index 3c5b8071c..2aad64270 100644 --- a/test/types/App.test-d.ts +++ b/test/types/App.test-d.ts @@ -1,4 +1,3 @@ -import { Agent } from 'node:http'; import { ConsoleLogger, LogLevel } from '@slack/logger'; import type { Installation, InstallationQuery } from '@slack/oauth'; import { expectAssignable, expectError, expectType } from 'tsd'; @@ -50,7 +49,6 @@ expectAssignable( expectAssignable( new App({ clientOptions: { - agent: new Agent(), allowAbsoluteUrls: false, logger: new ConsoleLogger(), retryConfig: { diff --git a/test/unit/App/default-error-handler.spec.ts b/test/unit/App/default-error-handler.spec.ts new file mode 100644 index 000000000..ef04f2d61 --- /dev/null +++ b/test/unit/App/default-error-handler.spec.ts @@ -0,0 +1,102 @@ +import assert from 'node:assert'; +import { WebAPIHTTPError, WebAPIPlatformError, WebAPIRateLimitedError } from '@slack/web-api'; +import sinon from 'sinon'; +import type App from '../../../src/App'; +import type { ReceiverEvent } from '../../../src/types'; +import { + createDummyReceiverEvent, + createFakeLogger, + FakeReceiver, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), +); + +describe('App default error handler', () => { + let fakeReceiver: FakeReceiver; + let dummyReceiverEvent: ReceiverEvent; + let app: App; + let fakeLogger: ReturnType; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeLogger = createFakeLogger(); + dummyReceiverEvent = createDummyReceiverEvent(); + + const MockApp = importApp(overrides); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves({ botToken: '', botId: '' }), + }); + }); + + it('should log a formatted message for WebAPIPlatformError', async () => { + app.use(() => { + throw new WebAPIPlatformError({ ok: false, error: 'channel_not_found' }); + }); + + try { + await fakeReceiver.sendEvent(dummyReceiverEvent); + assert.fail('should have thrown'); + } catch (_) { + assert.ok(fakeLogger.error.calledOnce); + assert.strictEqual(fakeLogger.error.firstCall.args[0], 'Slack API error: channel_not_found'); + } + }); + + it('should log a formatted message for WebAPIRateLimitedError', async () => { + app.use(() => { + throw new WebAPIRateLimitedError(30); + }); + + try { + await fakeReceiver.sendEvent(dummyReceiverEvent); + assert.fail('should have thrown'); + } catch (_) { + assert.ok(fakeLogger.error.calledOnce); + assert.strictEqual(fakeLogger.error.firstCall.args[0], 'Rate limited, retry after 30s'); + } + }); + + it('should log a formatted message for WebAPIHTTPError', async () => { + app.use(() => { + throw new WebAPIHTTPError(500, 'Internal Server Error', {}, ''); + }); + + try { + await fakeReceiver.sendEvent(dummyReceiverEvent); + assert.fail('should have thrown'); + } catch (_) { + assert.ok(fakeLogger.error.calledOnce); + assert.strictEqual(fakeLogger.error.firstCall.args[0], 'HTTP error 500: Internal Server Error'); + } + }); + + it('should log the raw error for unknown error types', async () => { + app.use(() => { + throw new Error('something unexpected'); + }); + + try { + await fakeReceiver.sendEvent(dummyReceiverEvent); + assert.fail('should have thrown'); + } catch (_) { + assert.ok(fakeLogger.error.calledOnce); + const loggedArg = fakeLogger.error.firstCall.args[0]; + assert.ok('code' in loggedArg); + assert.strictEqual(loggedArg.message, 'something unexpected'); + } + }); +}); diff --git a/test/unit/App/middlewares/arguments.spec.ts b/test/unit/App/middlewares/arguments.spec.ts index dd70cdf68..b6338bfe5 100644 --- a/test/unit/App/middlewares/arguments.spec.ts +++ b/test/unit/App/middlewares/arguments.spec.ts @@ -19,7 +19,6 @@ import { noop, noopMiddleware, type Override, - withAxiosPost, withChatStream, withConversationContext, withMemoryStore, @@ -59,8 +58,7 @@ describe('App middleware and listener arguments', () => { describe('authorize', () => { it('should extract valid enterprise_id in a shared channel #935', async () => { - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); const fakeHandler = sinon.fake(); @@ -96,8 +94,7 @@ describe('App middleware and listener arguments', () => { sinon.assert.calledOnce(fakeHandler); }); it('should be skipped for tokens_revoked events #674', async () => { - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); const fakeAuthorize = sinon.fake.resolves({}); const fakeHandler = sinon.fake(); @@ -137,8 +134,7 @@ describe('App middleware and listener arguments', () => { sinon.assert.calledOnce(fakeHandler); }); it('should be skipped for app_uninstalled events #674', async () => { - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); const fakeAuthorize = sinon.fake.resolves({}); const fakeHandler = sinon.fake(); @@ -180,11 +176,15 @@ describe('App middleware and listener arguments', () => { const responseText = 'response'; const response_url = 'https://fake.slack/response_url'; const action_id = 'block_action_id'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 200 })); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + clientOptions: { fetch: fakeFetch }, + }); app.action(action_id, async ({ respond }) => { await respond(responseText); }); @@ -207,19 +207,24 @@ describe('App middleware and listener arguments', () => { ); sinon.assert.notCalled(fakeErrorHandler); - // Assert that each call to fakeAxiosPost had the right arguments - sinon.assert.calledOnceWithExactly(fakeAxiosPost, response_url, { text: responseText }); + sinon.assert.calledOnce(fakeFetch); + assert.equal(fakeFetch.firstCall.args[0], response_url); + assert.deepEqual(JSON.parse(fakeFetch.firstCall.args[1].body), { text: responseText }); }); it('should respond with a response object', async () => { const responseObject = { text: 'response' }; const response_url = 'https://fake.slack/response_url'; const action_id = 'block_action_id'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 200 })); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + clientOptions: { fetch: fakeFetch }, + }); app.action(action_id, async ({ respond }) => { await respond(responseObject); }); @@ -241,17 +246,22 @@ describe('App middleware and listener arguments', () => { ), ); - // Assert that each call to fakeAxiosPost had the right arguments - sinon.assert.calledOnceWithExactly(fakeAxiosPost, response_url, responseObject); + sinon.assert.calledOnce(fakeFetch); + assert.equal(fakeFetch.firstCall.args[0], response_url); + assert.deepEqual(JSON.parse(fakeFetch.firstCall.args[1].body), responseObject); }); it('should be able to use respond for view_submission payloads', async () => { const responseObject = { text: 'response' }; const responseUrl = 'https://fake.slack/response_url'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 200 })); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + clientOptions: { fetch: fakeFetch }, + }); app.view('view-id', async ({ respond }) => { await respond(responseObject); }); @@ -276,8 +286,9 @@ describe('App middleware and listener arguments', () => { ), ); - // Assert that each call to fakeAxiosPost had the right arguments - sinon.assert.calledOnceWithExactly(fakeAxiosPost, responseUrl, responseObject); + sinon.assert.calledOnce(fakeFetch); + assert.equal(fakeFetch.firstCall.args[0], responseUrl); + assert.deepEqual(JSON.parse(fakeFetch.firstCall.args[1].body), responseObject); }); }); @@ -891,8 +902,7 @@ describe('App middleware and listener arguments', () => { describe('context', () => { it('should be able to use the app_installed_team_id when provided by the payload', async () => { - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); const callback_id = 'view-id'; const app_installed_team_id = 'T-installed-workspace'; @@ -921,8 +931,7 @@ describe('App middleware and listener arguments', () => { }); it('should have function executed event details from a custom step payload', async () => { - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); const callbackId = 'reverse_string'; const functionBotAccessToken = 'xwfp-example'; @@ -963,8 +972,7 @@ describe('App middleware and listener arguments', () => { }); it('should have function executed event details from a block actions payload', async () => { - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + overrides = buildOverrides([withNoopWebClient()]); const MockApp = importApp(overrides); const callbackId = 'reverse_string_button'; const functionBotAccessToken = 'xwfp-example'; diff --git a/test/unit/App/middlewares/global.spec.ts b/test/unit/App/middlewares/global.spec.ts index 124dc6614..46ab98417 100644 --- a/test/unit/App/middlewares/global.spec.ts +++ b/test/unit/App/middlewares/global.spec.ts @@ -103,9 +103,9 @@ describe('App global middleware Processing', () => { assert(fakeMiddleware.notCalled); assert(fakeLogger.warn.called); - assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); + assert.instanceOf(fakeErrorHandler.firstCall.args[0], AuthorizationError); assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'code', ErrorCode.AuthorizationError); - assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'original', dummyAuthorizationError.original); + assert.strictEqual(fakeErrorHandler.firstCall.args[0].original, dummyAuthorizationError); assert(fakeAck.called); }); diff --git a/test/unit/WorkflowStep.spec.ts b/test/unit/WorkflowStep.spec.ts deleted file mode 100644 index 0dbf1c993..000000000 --- a/test/unit/WorkflowStep.spec.ts +++ /dev/null @@ -1,388 +0,0 @@ -import path from 'node:path'; -import type { WebClient } from '@slack/web-api'; -import { assert } from 'chai'; -import sinon from 'sinon'; -import { WorkflowStepInitializationError } from '../../src/errors'; -import type { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware, WorkflowStepEdit } from '../../src/types'; -import { - type AllWorkflowStepMiddlewareArgs, - type SlackWorkflowStepMiddlewareArgs, - WorkflowStep, - type WorkflowStepConfig, - type WorkflowStepEditMiddlewareArgs, - type WorkflowStepExecuteMiddlewareArgs, - type WorkflowStepMiddleware, - type WorkflowStepSaveMiddlewareArgs, -} from '../../src/WorkflowStep'; -import { noopVoid, type Override, proxyquire } from './helpers'; - -function importWorkflowStep(overrides: Override = {}): typeof import('../../src/WorkflowStep') { - const absolutePath = path.resolve(__dirname, '../../src/WorkflowStep'); - return proxyquire(absolutePath, overrides); -} - -const MOCK_CONFIG_SINGLE = { - edit: noopVoid, - save: noopVoid, - execute: noopVoid, -}; - -const MOCK_CONFIG_MULTIPLE = { - edit: [noopVoid, noopVoid], - save: [noopVoid], - execute: [noopVoid, noopVoid, noopVoid], -}; - -describe('WorkflowStep class', () => { - describe('constructor', () => { - it('should accept config as single functions', async () => { - const ws = new WorkflowStep('test_callback_id', MOCK_CONFIG_SINGLE); - assert.isNotNull(ws); - }); - - it('should accept config as multiple functions', async () => { - const ws = new WorkflowStep('test_callback_id', MOCK_CONFIG_MULTIPLE); - assert.isNotNull(ws); - }); - }); - - describe('getMiddleware', () => { - it('should not call next if a workflow step event', async () => { - const ws = new WorkflowStep('test_edit_callback_id', MOCK_CONFIG_SINGLE); - const middleware = ws.getMiddleware(); - const fakeEditArgs = createFakeStepEditAction() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - - const fakeNext = sinon.spy(); - fakeEditArgs.next = fakeNext; - - await middleware(fakeEditArgs); - - assert(fakeNext.notCalled); - }); - - it('should call next if valid workflow step with mismatched callback_id', async () => { - const ws = new WorkflowStep('bad_callback_id', MOCK_CONFIG_SINGLE); - const middleware = ws.getMiddleware(); - const fakeEditArgs = createFakeStepEditAction() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - - const fakeNext = sinon.spy(); - fakeEditArgs.next = fakeNext; - - await middleware(fakeEditArgs); - - assert(fakeNext.called); - }); - - it('should call next if not a workflow step event', async () => { - const ws = new WorkflowStep('test_view_callback_id', MOCK_CONFIG_SINGLE); - const middleware = ws.getMiddleware(); - const fakeViewArgs = createFakeViewEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - - const fakeNext = sinon.spy(); - fakeViewArgs.next = fakeNext; - - await middleware(fakeViewArgs); - - assert(fakeNext.called); - }); - }); - - describe('validate', () => { - it('should throw an error if callback_id is not valid', async () => { - const { validate } = importWorkflowStep(); - - // intentionally casting to string to trigger failure - const badId = {} as string; - const validationFn = () => validate(badId, MOCK_CONFIG_SINGLE); - - const expectedMsg = 'WorkflowStep expects a callback_id as the first argument'; - assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); - }); - - it('should throw an error if config is not an object', async () => { - const { validate } = importWorkflowStep(); - - // intentionally casting to WorkflowStepConfig to trigger failure - const badConfig = '' as unknown as WorkflowStepConfig; - - const validationFn = () => validate('callback_id', badConfig); - const expectedMsg = 'WorkflowStep expects a configuration object as the second argument'; - assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); - }); - - it('should throw an error if required keys are missing', async () => { - const { validate } = importWorkflowStep(); - - // intentionally casting to WorkflowStepConfig to trigger failure - const badConfig = { - edit: async () => {}, - } as unknown as WorkflowStepConfig; - - const validationFn = () => validate('callback_id', badConfig); - const expectedMsg = 'WorkflowStep is missing required keys: save, execute'; - assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); - }); - - it('should throw an error if lifecycle props are not a single callback or an array of callbacks', async () => { - const { validate } = importWorkflowStep(); - - // intentionally casting to WorkflowStepConfig to trigger failure - const badConfig = { - edit: async () => {}, - save: {}, - execute: async () => {}, - } as unknown as WorkflowStepConfig; - - const validationFn = () => validate('callback_id', badConfig); - const expectedMsg = 'WorkflowStep save property must be a function or an array of functions'; - assert.throws(validationFn, WorkflowStepInitializationError, expectedMsg); - }); - }); - - describe('isStepEvent', () => { - it('should return true if recognized workflow step payload type', async () => { - const fakeEditArgs = createFakeStepEditAction() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - const fakeSaveArgs = createFakeStepSaveEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & - AllMiddlewareArgs; - - const { isStepEvent } = importWorkflowStep(); - - const editIsStepEvent = isStepEvent(fakeEditArgs); - const viewIsStepEvent = isStepEvent(fakeSaveArgs); - const executeIsStepEvent = isStepEvent(fakeExecuteArgs); - - assert.isTrue(editIsStepEvent); - assert.isTrue(viewIsStepEvent); - assert.isTrue(executeIsStepEvent); - }); - - it('should return false if not a recognized workflow step payload type', async () => { - const fakeEditArgs = createFakeStepEditAction() as unknown as AnyMiddlewareArgs; - fakeEditArgs.payload.type = 'invalid_type'; - - const { isStepEvent } = importWorkflowStep(); - const actionIsStepEvent = isStepEvent(fakeEditArgs); - - assert.isFalse(actionIsStepEvent); - }); - }); - - describe('prepareStepArgs', () => { - it('should remove next() from all original event args', async () => { - const fakeEditArgs = createFakeStepEditAction() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - const fakeSaveArgs = createFakeStepSaveEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & - AllMiddlewareArgs; - - const { prepareStepArgs } = importWorkflowStep(); - - const editStepArgs = prepareStepArgs(fakeEditArgs); - const viewStepArgs = prepareStepArgs(fakeSaveArgs); - const executeStepArgs = prepareStepArgs(fakeExecuteArgs); - - assert.notExists(editStepArgs.next); - assert.notExists(viewStepArgs.next); - assert.notExists(executeStepArgs.next); - }); - - it('should augment workflow_step_edit args with step and configure()', async () => { - const fakeArgs = createFakeStepEditAction(); - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs as AllWorkflowStepMiddlewareArgs); - - assert.exists(stepArgs.step); - assert.property(stepArgs, 'configure'); - }); - - it('should augment view_submission with step and update()', async () => { - const fakeArgs = createFakeStepSaveEvent(); - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs( - fakeArgs as unknown as AllWorkflowStepMiddlewareArgs, - ); - - assert.exists(stepArgs.step); - assert.property(stepArgs, 'update'); - }); - - it('should augment workflow_step_execute with step, complete() and fail()', async () => { - const fakeArgs = createFakeStepExecuteEvent(); - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs( - fakeArgs as unknown as AllWorkflowStepMiddlewareArgs, - ); - - assert.exists(stepArgs.step); - assert.property(stepArgs, 'complete'); - assert.property(stepArgs, 'fail'); - }); - }); - - describe('step utility functions', () => { - it('configure should call views.open', async () => { - const fakeEditArgs = createFakeStepEditAction() as unknown as AllWorkflowStepMiddlewareArgs; - - const fakeClient = { views: { open: sinon.spy() } }; - fakeEditArgs.client = fakeClient as unknown as WebClient; - - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const editStepArgs = prepareStepArgs( - fakeEditArgs, - ) as AllWorkflowStepMiddlewareArgs; - - await editStepArgs.configure({ blocks: [] }); - - assert(fakeClient.views.open.called); - }); - - it('update should call workflows.updateStep', async () => { - const fakeSaveArgs = createFakeStepSaveEvent() as unknown as AllWorkflowStepMiddlewareArgs; - - const fakeClient = { workflows: { updateStep: sinon.spy() } }; - fakeSaveArgs.client = fakeClient as unknown as WebClient; - - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const saveStepArgs = prepareStepArgs( - fakeSaveArgs, - ) as AllWorkflowStepMiddlewareArgs; - - await saveStepArgs.update(); - - assert(fakeClient.workflows.updateStep.called); - }); - - it('complete should call workflows.stepCompleted', async () => { - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & - AllMiddlewareArgs; // eslint-disable-line max-len - - const fakeClient = { workflows: { stepCompleted: sinon.spy() } }; - fakeExecuteArgs.client = fakeClient as unknown as WebClient; - - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const executeStepArgs = prepareStepArgs( - fakeExecuteArgs, - ) as AllWorkflowStepMiddlewareArgs; - - await executeStepArgs.complete(); - - assert(fakeClient.workflows.stepCompleted.called); - }); - - it('fail should call workflows.stepFailed', async () => { - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & - AllMiddlewareArgs; // eslint-disable-line max-len - - const fakeClient = { workflows: { stepFailed: sinon.spy() } }; - fakeExecuteArgs.client = fakeClient as unknown as WebClient; - - const { prepareStepArgs } = importWorkflowStep(); - // casting to returned type because prepareStepArgs isn't built to do so - const executeStepArgs = prepareStepArgs( - fakeExecuteArgs, - ) as AllWorkflowStepMiddlewareArgs; - - await executeStepArgs.fail({ error: { message: 'Failed' } }); - - assert(fakeClient.workflows.stepFailed.called); - }); - }); - - describe('processStepMiddleware', () => { - it('should call each callback in user-provided middleware', async () => { - const { ...fakeArgs } = createFakeStepEditAction() as unknown as AllWorkflowStepMiddlewareArgs; - const { processStepMiddleware } = importWorkflowStep(); - - const fn1 = sinon.spy((async ({ next: continuation }) => { - await continuation(); - }) as Middleware); - const fn2 = sinon.spy(async () => {}); - const fakeMiddleware = [fn1, fn2] as WorkflowStepMiddleware; - - await processStepMiddleware(fakeArgs, fakeMiddleware); - - assert(fn1.called); - assert(fn2.called); - }); - }); -}); - -// TODO: need middleware test utilities like wrapping in AllMiddleWareArgs (creating say, respond, context) -// same for other kinds of middleware -// this stuff probably already exists -function createFakeStepEditAction() { - return { - body: { - callback_id: 'test_edit_callback_id', - trigger_id: 'test_edit_trigger_id', - }, - payload: { - type: 'workflow_step_edit', - callback_id: 'test_edit_callback_id', - }, - action: { - workflow_step: {}, - }, - context: {}, - }; -} - -function createFakeStepSaveEvent() { - return { - body: { - callback_id: 'test_save_callback_id', - trigger_id: 'test_save_trigger_id', - workflow_step: { - workflow_step_edit_id: '', - }, - }, - payload: { - type: 'workflow_step', - callback_id: 'test_save_callback_id', - }, - context: {}, - }; -} - -function createFakeStepExecuteEvent() { - return { - body: { - callback_id: 'test_execute_callback_id', - trigger_id: 'test_execute_trigger_id', - }, - event: { - workflow_step: {}, - }, - payload: { - type: 'workflow_step_execute', - callback_id: 'test_execute_callback_id', - workflow_step: { - workflow_step_execute_id: '', - }, - }, - context: {}, - }; -} - -function createFakeViewEvent() { - return { - body: { - callback_id: 'test_view_callback_id', - trigger_id: 'test_view_trigger_id', - workflow_step: { - workflow_step_edit_id: '', - }, - }, - payload: { - type: 'view_submission', - callback_id: 'test_view_callback_id', - }, - context: {}, - }; -} diff --git a/test/unit/context/create-respond.spec.ts b/test/unit/context/create-respond.spec.ts index a24a37e57..fe02d1259 100644 --- a/test/unit/context/create-respond.spec.ts +++ b/test/unit/context/create-respond.spec.ts @@ -1,39 +1,42 @@ -import type { AxiosInstance } from 'axios'; +import type { FetchFunction } from '@slack/web-api'; import { assert } from 'chai'; import sinon from 'sinon'; import { createRespond } from '../../../src/context'; describe('createRespond', () => { it('should post to the response URL with text when given a string', async () => { - const axiosInstance = { post: sinon.stub().resolves({ status: 200 }) }; - const respond = createRespond(axiosInstance as unknown as AxiosInstance, 'https://hooks.slack.com/response/123'); + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 200 })); + const respond = createRespond(fakeFetch as unknown as FetchFunction, 'https://hooks.slack.com/response/123'); await respond('hello'); - assert(axiosInstance.post.calledOnce); - assert.equal(axiosInstance.post.firstCall.args[0], 'https://hooks.slack.com/response/123'); - assert.deepEqual(axiosInstance.post.firstCall.args[1], { text: 'hello' }); + assert(fakeFetch.calledOnce); + assert.equal(fakeFetch.firstCall.args[0], 'https://hooks.slack.com/response/123'); + assert.equal(fakeFetch.firstCall.args[1].method, 'POST'); + assert.deepEqual(JSON.parse(fakeFetch.firstCall.args[1].body), { text: 'hello' }); }); it('should post to the response URL with the full message object', async () => { const url = 'https://hooks.slack.com/response/123'; - const axiosInstance = { post: sinon.stub().resolves({ status: 200 }) }; - const respond = createRespond(axiosInstance as unknown as AxiosInstance, url); + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 200 })); + const respond = createRespond(fakeFetch as unknown as FetchFunction, url); const message = { text: 'hello', replace_original: true }; await respond(message); - assert(axiosInstance.post.calledOnceWithExactly(url, message)); + assert(fakeFetch.calledOnce); + assert.equal(fakeFetch.firstCall.args[0], url); + assert.deepEqual(JSON.parse(fakeFetch.firstCall.args[1].body), message); }); it('should use the correct response URL', async () => { const url = 'https://hooks.slack.com/response/456'; - const axiosInstance = { post: sinon.stub().resolves({ status: 200 }) }; - const respond = createRespond(axiosInstance as unknown as AxiosInstance, url); + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 200 })); + const respond = createRespond(fakeFetch as unknown as FetchFunction, url); await respond('test'); - assert(axiosInstance.post.calledOnce); - assert.equal(axiosInstance.post.firstCall.args[0], url); + assert(fakeFetch.calledOnce); + assert.equal(fakeFetch.firstCall.args[0], url); }); }); diff --git a/test/unit/helpers/app.ts b/test/unit/helpers/app.ts index 11aa076e3..0883de9db 100644 --- a/test/unit/helpers/app.ts +++ b/test/unit/helpers/app.ts @@ -131,16 +131,6 @@ export function withSetStatus(spy: SinonSpy): Override { }; } -export function withAxiosPost(spy: SinonSpy): Override { - return { - axios: { - create: () => ({ - post: spy, - }), - }, - }; -} - export function withSuccessfulBotUserFetchingWebClient(botId: string, botUserId: string): Override { return { '@slack/web-api': { diff --git a/tsconfig.json b/tsconfig.json index 93c49d8eb..533df7dea 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/tsconfig", - "extends": "@tsconfig/node18/tsconfig.json", + "extends": "@tsconfig/node20/tsconfig.json", "compilerOptions": { "declaration": true, "declarationMap": true,