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..bf2378116 --- /dev/null +++ b/.changeset/use-native-fetch.md @@ -0,0 +1,7 @@ +--- +"@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 a `dispatcher` option to `SocketModeReceiver` for proxy/TLS configuration in socket mode. + +`respond()` now throws a `RespondError` when the `response_url` request returns a non-2xx status (restoring the throw-on-failure behavior that axios provided) and resolves to a `Response` on success rather than an axios response object. diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 8b2274427..617cb81cb 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..084ccfb5b 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. @@ -125,7 +125,7 @@ Listeners receive a single object with these properties (availability depends on 4. **Don't duplicate `package.json` values** -- reference it for versions, engines, and dependency lists. 5. **Don't add `WorkflowStep` code** -- it is deprecated. Use `CustomFunction` and `app.function()` instead. 6. **Build before running unit tests directly** -- `npm test` handles this automatically, but `npm run test:unit` requires a build to exist first. -7. **Keep the Receiver abstraction clean** -- receivers should only handle transport concerns (ingesting events, sending ack responses). Business logic belongs in middleware and listeners. +7. **Keep the Receiver abstraction clean** -- receivers should only handle transport concerns (ingesting events, sending ack responses). Business logic belongs in middleware and listeners. Do **not** add receiver-specific options to `AppOptions`: configuration that only a particular receiver understands (e.g. proxy/TLS via an undici `dispatcher` for Socket Mode) must be set on the receiver instance, which is then passed to `App` via the `receiver` option. The long-term goal is for `App` to be fully agnostic of which receiver is in use, so avoid growing the App's surface with transport concerns. 8. **Prefer middleware for cross-cutting concerns** -- authorization, logging, validation, and feature-level request handling (like `Assistant`) all use the middleware pattern. 9. **TypeScript types are part of the API** -- changes to exported types are breaking changes. Add type tests for new public types. 10. **Every listener type needs four things:** type definitions, built-in middleware matchers, an App method, and tests. diff --git a/docs/english/migration/migration-v5.md b/docs/english/migration/migration-v5.md new file mode 100644 index 000000000..6125e5838 --- /dev/null +++ b/docs/english/migration/migration-v5.md @@ -0,0 +1,296 @@ +--- +sidebar_label: Migrating to v5 +--- + +# Migrating @slack/bolt from v4 to v5 + +_Minimum Node.js version: 20_ + +Bolt for JS v5 follows the Node Slack SDK's shift from axios to the native [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API). It also removes the deprecated Workflow Steps feature (retired by Slack in September 2024) and raises the minimum Node.js version to 20. + +All internal `@slack/*` Node Slack SDK dependencies have been bumped to their next major versions. See the section below on [Upgrading the Node Slack SDK dependencies]({#node-slack-sdk-dependencies}) for information and migration instructions. + +If your app doesn't use proxy/TLS configuration or inspect `respond()` utility function return values, this upgrade is likely a version bump and done. + +--- + +## Breaking Changes {#breaking-changes} + +### We've raised the minimum Node.js version to 20 {#minimum-node-version} + +We've dropped support for Node.js 18. Node.js 20 or later is required. + +--- + +### TypeScript consumers target ES2022 or later {#typescript-es2022-target} + +Bolt v5 depends on v8 of the `@slack/web-api` Node Slack SDK package, whose error classes use the ES2022 [`Error(message, { cause })`](https://developer.mozilla.org/en-US/docs/Web/API/Error/Error) constructor. Its type definitions reference the global `ErrorOptions` type. + +If your project's `tsconfig.json` targets an older ECMAScript library and does not set `skipLibCheck`, your build may fail after upgrading with an error like: + +```text +error TS2304: Cannot find name 'ErrorOptions'. +``` + +To fix it we recommend raising your compilation target to `es2022` or later, as it aligns with the Node.js 20 baseline. + +Alternatively, set `"skipLibCheck": true` to skip type-checking of dependency declaration files. Note that Bolt's own build and its sample apps already extend [`@tsconfig/node20`](https://www.npmjs.com/package/@tsconfig/node20), which targets `es2022`, so projects based on those templates are unaffected. + +--- + +### We've removed the `agent` and `clientTls` options from the `AppOptions` interface {#removed-agent-clienttls} + +You should configure transport via the `clientOptions.fetch` option or use the Node.js built-in proxy support. + +**Before (v4):** + +```typescript +import { App } from '@slack/bolt'; +import { HttpsProxyAgent } from 'https-proxy-agent'; +import fs from 'node:fs'; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, + agent: new HttpsProxyAgent('http://corporate.proxy:8080'), + clientTls: { + cert: fs.readFileSync('/path/to/client-cert.pem'), + key: fs.readFileSync('/path/to/client-key.pem'), + }, +}); +``` + +#### Preferred: Built-in proxy support {#built-in-proxy-support} + +Node.js can read proxy environment variables natively via [`http.setGlobalProxyFromEnv()`](https://nodejs.org/docs/latest/api/http.html#httpsetglobalproxyfromenvproxyenv). Call it once at startup and `globalThis.fetch` routes through your proxy automatically, no extra packages needed. + +##### Option A: programmatically call once at startup {#programmatically-call-startup} + +```typescript +import http from 'node:http'; +import { App } from '@slack/bolt'; + +http.setGlobalProxyFromEnv(); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, +}); +``` + +##### Option B: use an environment variable {#use-an-environment-variable} + +```bash +NODE_USE_ENV_PROXY=1 HTTPS_PROXY=http://corporate.proxy:8080 node app.js +``` + +```typescript +import { App } from '@slack/bolt'; + +// No proxy configuration needed — globalThis.fetch respects the environment +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, +}); +``` + +#### Alternative: use an undici `Dispatcher` instance for proxy and TLS {#undici-dispatcher-proxy-tls} + +If you need per-client configuration, use the `clientOptions.fetch` option with an [undici](https://undici.nodejs.org/) `Dispatcher` instance: + +```typescript +import { App } from '@slack/bolt'; +import { fetch, ProxyAgent, Agent } from 'undici'; +import fs from 'node:fs'; + +// Proxy only +const proxyDispatcher = new ProxyAgent('http://corporate.proxy:8080'); + +// TLS only +const tlsDispatcher = new Agent({ + connect: { + cert: fs.readFileSync('/path/to/client-cert.pem'), + key: fs.readFileSync('/path/to/client-key.pem'), + ca: fs.readFileSync('/path/to/ca-cert.pem'), + }, +}); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + signingSecret: process.env.SLACK_SIGNING_SECRET, + clientOptions: { + fetch: (url, init) => fetch(url, { ...init, dispatcher: tlsDispatcher }), + }, +}); +``` + +--- + +### We've updated the `SocketModeReceiver` class to accept a `dispatcher` option instead of proxy agents {#socketmodereceiver-dispatcher} + +The `SocketModeReceiver` class now accepts a `dispatcher` option for unified proxy and TLS configuration of both the WebSocket connection and HTTP API calls. The `dispatcher` option accepts any undici-compatible `Dispatcher` instance. + +**Before (v4):** + +```typescript +import { App } from '@slack/bolt'; +import { HttpsProxyAgent } from 'https-proxy-agent'; + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + appToken: process.env.SLACK_APP_TOKEN, + socketMode: true, + agent: new HttpsProxyAgent('http://corporate.proxy:8080'), +}); +``` + +#### Preferred: Use built-in proxy support {#socketmode-built-in-proxy-support} + +Node.js can read proxy environment variables natively via [`http.setGlobalProxyFromEnv()`](https://nodejs.org/docs/latest/api/http.html#httpsetglobalproxyfromenvproxyenv). Call it once at startup and both the WebSocket connection and API calls route through your proxy automatically. + +```typescript +import http from 'node:http'; +import { App } from '@slack/bolt'; + +http.setGlobalProxyFromEnv(); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + appToken: process.env.SLACK_APP_TOKEN, + socketMode: true, +}); +``` + +#### Alternative: Use an undici `Dispatcher` instance for proxy and TLS {#socketmode-undici-dispatcher-proxy-tls} + +If you need per-client configuration, pass a `dispatcher` option to both the `SocketModeReceiver` class (for the WebSocket connection) and the `clientOptions.fetch` option (for the app's internal `WebClient` class API calls): + +```typescript +import { App, SocketModeReceiver } from '@slack/bolt'; +import { fetch, ProxyAgent } from 'undici'; + +const dispatcher = new ProxyAgent('http://corporate.proxy:8080'); + +const receiver = new SocketModeReceiver({ + appToken: process.env.SLACK_APP_TOKEN, + dispatcher, +}); + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + receiver, + clientOptions: { + fetch: (url, init) => fetch(url, { ...init, dispatcher }), + }, +}); +``` + +--- + +### We've removed Workflow Steps from Apps {#removed-workflow-steps} + +The Workflow Steps from Apps feature [was retired in September 2024](/changelog/2023-08-workflow-steps-from-apps-step-back/). The `WorkflowStep` class, the `app.step()` method, and all related types have been deleted from Bolt for JS. You should remove any imports of the `WorkflowStep` class or the `WorkflowStepEdit` type. + +Use the `app.function()` method with custom functions instead. + +--- + +### We've updated the `respond()` utility function to return a native `Response` object {#respond-returns-fetch-response} + +The `respond()` utility function now uses native fetch internally. If you inspect the return value, the shape has changed from an `AxiosResponse` object to a standard Fetch [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response) object. + +**Before (v4):** + +```typescript +app.command('/ticket', async ({ command, ack, respond }) => { + await ack(); + const result = await respond(`Ticket created: ${command.text}`); + // result was an AxiosResponse + console.log(result.status); // 200 + console.log(result.data); // response body (pre-parsed) + console.log(result.headers); // AxiosHeaders object +}); +``` + +**After (v5):** + +```typescript +app.command('/ticket', async ({ command, ack, respond }) => { + await ack(); + const result = await respond(`Ticket created: ${command.text}`); + // result is a native Fetch Response + console.log(result.status); // 200 + console.log(await result.text()); // response body (call .text() or .json()) + console.log(result.headers); // Headers object +}); +``` + +If you're only calling `await respond(...)` without using the return value (the common case), no changes are needed. + +--- + +### We've upgraded the Node Slack SDK dependencies {#node-slack-sdk-dependencies} + +All Node Slack SDK packages have been bumped to their next major versions. + +* The `logger` package has been updated to v5. +* The `oauth` package has been updated to v4. +* The `types` package has been updated to v3. + +Three packages have more substantial breaking changes: + +* The `socket-mode` package has been updated to v3. See the guide on [migrating @slack/socket-mode from v2 to v3](/tools/node-slack-sdk/migration/socket-mode/migrating-socket-mode-package-to-v3) for handling breaking changes. +* The `web-api` package has been updated to v8. See the guide on [migrating @slack/web-api from v7 to v8](/tools/node-slack-sdk/migration/web-api/migrating-web-api-package-to-v8) for handling breaking changes. + +--- + +### We've improved error handling throughout {#error-handling} + +This version of Bolt leverages the new error classes from v8 of the `@slack/web-api` Node Slack SDK package. Errors thrown by the internal `WebClient` class are now proper `Error` subclasses. + +**Before (v4):** + +```typescript +import { App } from '@slack/bolt'; + +const app = new App({ /* ... */ }); + +app.error(async ({ error }) => { + if ('code' in error && error.code === 'slack_webapi_platform_error') { + console.log((error as any).data?.error); + } +}); +``` + +**After (v5):** + +```typescript +import { App } from '@slack/bolt'; +import { WebAPIPlatformError, WebAPIRequestError } from '@slack/web-api'; + +const app = new App({ /* ... */ }); + +app.error(async ({ error }) => { + if (error instanceof WebAPIPlatformError) { + console.log(error.data.error); // e.g. 'channel_not_found' + } else if (error instanceof WebAPIRequestError) { + console.log(error.cause); // the underlying fetch/network error + } +}); +``` + +--- + +## New Features {#new-features} + +### We've added a `dispatcher` option to the `SocketModeReceiver` class {#new-dispatcher-option} + +Unified proxy and TLS configuration for both WebSocket connections and HTTP API calls. Pass any undici-compatible `Dispatcher` instance. See the section above on [the updated `SocketModeReceiver` class](#socketmodereceiver-dispatcher). + +### We've added `instanceof` operator support to error classes {#instanceof-errors} + +Both Bolt's own error classes (e.g., `AppInitializationError`, `AuthorizationError`) and the underlying `@slack/web-api` Node Slack SDK package error classes (e.g., `WebAPIPlatformError`, `WebAPIRequestError`) now properly extend the `Error` class. Use the `instanceof` operator for type-safe error handling instead of string comparisons on the `error.code` property. + +### We've made the `app.function()` method the sole custom step mechanism {#app-function} + +While the `app.function()` method already existed in Bolt v4, it is now the only way to handle custom function executions (replacing the removed `app.step()` method and `WorkflowStep` class). It provides `complete()` and `fail()` callback functions for signaling outcomes, and an `inputs` property for accessing function parameters. diff --git a/docs/english/reference.md b/docs/english/reference.md index b9765081f..390dea6f9 100644 --- a/docs/english/reference.md +++ b/docs/english/reference.md @@ -129,8 +129,6 @@ App options are passed into the `App` constructor. When the `receiver` argument | Option | Description | | :--- | :--- | | `receiver` | An instance of `Receiver` that parses and handles incoming events. Must conform to the [`Receiver` interface](/tools/bolt-js/concepts/receiver), which includes `init(app)`, `start()`, and `stop()`. More information about receivers is [in the documentation](/tools/bolt-js/concepts/receiver). | -| `agent` | Optional HTTP `Agent` used to set up proxy support. Read more about custom agents in the [Node Slack SDK documentation](/tools/node-slack-sdk/web-api#proxy-requests-with-a-custom-agent). | -| `clientTls` | Optional `string` to set a custom TLS configuration for HTTP client requests. Must be one of: `"pfx"`, `"key"`, `"passphrase"`, `"cert"`, or `"ca"`. | | `convoStore` | A store to set and retrieve state-related conversation information. `set()` sets conversation state and `get()` fetches it. By default, apps have access to an in-memory store. More information and an example can be found [in the documentation](/tools/bolt-js/legacy/conversation-store). | | `token` | A `string` from your app's configuration (under "Settings" > "Install App") required for calling the Web API. May not be passed when using `authorize`, `orgAuthorize`, or OAuth. | | `botId` | Can only be used when `authorize` is not defined. The optional `botId` is the ID for your bot token (ex: `B12345`) which can be used to ignore messages sent by your app. If a `xoxb-` token is passed to your app, this value will automatically be retrieved by your app calling the [`auth.test` method](/reference/methods/auth.test). | @@ -141,6 +139,7 @@ App options are passed into the `App` constructor. When the `receiver` argument | `extendedErrorHandler` | Option that accepts a `boolean` value. When set to `true`, the global error handler is passed an object with additional request context. Available from version 3.8.0, defaults to `false`. More information on advanced error handling can be found [in the documentation](/tools/bolt-js/concepts/error-handling). | | `ignoreSelf` | `boolean` to enable a middleware function that ignores any messages coming from your app. Requires a `botId`. Defaults to `true`. | | `clientOptions.slackApiUrl` | Allows setting a custom endpoint for the Slack API. Used most often for testing. | +| `clientOptions.fetch` | A custom `fetch` implementation (conforming to the WHATWG Fetch standard) used for all HTTP requests to Slack Web API calls, OAuth, and `respond()`. Use this to configure proxy or custom TLS behavior (for example, by wrapping a request with an undici `Dispatcher`). A global proxy can alternatively be configured with `http.setGlobalProxyFromEnv()`. | | `socketMode` | Option that accepts a `boolean` value. When set to `true` the app is started in [Socket Mode](/tools/bolt-js/concepts/socket-mode), i.e. it allows your app to connect and receive data from Slack via a WebSocket connection. Defaults to `false`. | `developerMode` | `boolean` to activate the developer mode. When set to `true` the `logLevel` is automatically set to `DEBUG` and `socketMode` is set to `true`. However, explicitly setting these two properties takes precedence over implicitly setting them via `developerMode`. Furthermore, a custom OAuth failure handler is provided to help debugging. Finally, the body of all incoming requests are logged and thus sensitive information like tokens might be contained in the logs. Defaults to `false`. | | `deferInitialization` | `boolean` to defer initialization of the app and places responsibility for manually calling the `async` `App#init()` method on the developer. `init()` must be called before `App#start()`. Defaults to `false`. | diff --git a/examples/custom-receiver/package-lock.json b/examples/custom-receiver/package-lock.json index 2a9ab4aac..187684df5 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 50a5d9218..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.17.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.17.0", - "resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-7.17.0.tgz", - "integrity": "sha512-jejr34a8B4L5AS713wOAx1LAqNkW16HVMDEa6sYBvFDc/llUBl8hXaiI4BwF+Al+Sug19Vn2O7iokTVIhVvZ1Q==", + "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 aa178789f..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.17.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..69796c0d6 100644 --- a/src/context/create-respond.ts +++ b/src/context/create-respond.ts @@ -1,12 +1,23 @@ -import type { AxiosInstance, AxiosResponse } from 'axios'; -import type { RespondArguments } from '../types'; +import type { FetchFunction } from '@slack/web-api'; +import { RespondError } from '../errors'; +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); + const response = await fetchFn(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(normalizedArgs), + }); + // fetch resolves regardless of status code. + // Throw so that failures (e.g. expired response_url, rate limits) reach the app's error handling. + if (!response.ok) { + throw new RespondError( + `Failed to respond to the response_url: ${response.status} ${response.statusText}`, + response.status, + ); + } + return response; }; } diff --git a/src/errors.ts b/src/errors.ts index ef250b44c..098a939d6 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -36,15 +36,14 @@ export enum ErrorCode { HTTPReceiverDeferredRequestError = 'slack_bolt_http_receiver_deferred_request_error', + RespondError = 'slack_bolt_respond_error', + /** * This value is used to assign to errors that occur inside the framework but do not have a code, to keep interfaces * in terms of CodedError. */ 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', @@ -143,6 +142,17 @@ export class HTTPReceiverDeferredRequestError extends Error implements CodedErro } } +export class RespondError extends Error implements CodedError { + public code = ErrorCode.RespondError; + + public statusCode: number; + + public constructor(message: string, statusCode: number) { + super(message); + this.statusCode = statusCode; + } +} + export class MultipleListenerError extends Error implements CodedError { public code = ErrorCode.MultipleListenerError; @@ -156,14 +166,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..adfee2da2 100644 --- a/test/unit/context/create-respond.spec.ts +++ b/test/unit/context/create-respond.spec.ts @@ -1,39 +1,67 @@ -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'; +import { ErrorCode, type RespondError } from '../../../src/errors'; 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); + }); + + it('should return the response when the request succeeds', async () => { + const response = new Response(null, { status: 200 }); + const fakeFetch = sinon.fake.resolves(response); + const respond = createRespond(fakeFetch as unknown as FetchFunction, 'https://hooks.slack.com/response/123'); + + const result = await respond('hello'); + + assert.equal(result, response); + }); + + it('should throw a RespondError when the response is not ok', async () => { + const fakeFetch = sinon.fake.resolves(new Response(null, { status: 404, statusText: 'Not Found' })); + const respond = createRespond(fakeFetch as unknown as FetchFunction, 'https://hooks.slack.com/response/123'); + + try { + await respond('hello'); + assert.fail('Expected respond to throw'); + } catch (error) { + const respondError = error as RespondError; + assert.equal(respondError.code, ErrorCode.RespondError); + assert.equal(respondError.statusCode, 404); + } }); }); 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,