diff --git a/.c8rc.json b/.c8rc.json new file mode 100644 index 000000000..7df66f95b --- /dev/null +++ b/.c8rc.json @@ -0,0 +1,7 @@ +{ + "include": ["src/**/*.ts"], + "exclude": ["**/*.spec.ts"], + "reporter": ["lcov", "text"], + "all": false, + "cache": true +} diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index b512c09d4..000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -node_modules \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index c59dfd582..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,304 +0,0 @@ -// -// SlackAPI JavaScript and TypeScript style -// --- -// This style helps maintainers enforce safe and consistent programming practices in this project. It is not meant to be -// comprehensive on its own or vastly different from existing styles. The goal is to inherit and aggregate as many of -// the communities' recommended styles for the technologies used as we can. When, and only when, we have a stated need -// to differentiate, we add more rules (or modify options). Therefore, the fewer rules directly defined in this file, -// the better. - -const jsDocPlugin = require('eslint-plugin-jsdoc'); - -const jsDocRecommendedRulesOff = Object.assign( - ...Object.keys(jsDocPlugin.configs.recommended.rules).map((rule) => ({ [rule]: 'off' })), -); - -module.exports = { - // This is a root of the project, ESLint should not look through parent directories to find more config - root: true, - - ignorePatterns: [ - // Ignore all build outputs and artifacts (node_modules, dotfiles, and dot directories are implicitly ignored) - '/dist', - '/coverage', - ], - - // These environments contain lists of global variables which are allowed to be accessed - env: { - // According to https://node.green, the target node version (v10) supports all important ES2018 features. But es2018 - // is not an option since it presumably doesn't introduce any new globals over ES2017. - es2017: true, - node: true, - }, - - extends: [ - // ESLint's recommended built-in rules: https://eslint.org/docs/rules/ - 'eslint:recommended', - - // Node plugin's recommended rules: https://github.com/mysticatea/eslint-plugin-node - 'plugin:node/recommended', - - // AirBnB style guide (without React) rules: https://github.com/airbnb/javascript. - 'airbnb-base', - - // JSDoc plugin's recommended rules - 'plugin:jsdoc/recommended', - ], - - rules: { - // JavaScript rules - // --- - // The top level of this configuration contains rules which apply to JavaScript (and will also be inherited for - // TypeScript). This section does not contain rules meant to override options or disable rules in the base - // configurations (ESLint, Node, AirBnb). Those rules are added in the final override. - - // Eliminate tabs to standardize on spaces for indentation. If you want to use tabs for something other than - // indentation, you may need to turn this rule off using an inline config comments. - 'no-tabs': 'error', - - // Bans use of comma as an operator because it can obscure side effects and is often an accident. - 'no-sequences': 'error', - - // Disallow the use of process.exit() - 'node/no-process-exit': 'error', - - // Allow safe references to functions before the declaration. Overrides AirBnB config. Not located in the override - // section below because a distinct override is necessary in TypeScript files. - 'no-use-before-define': ['error', 'nofunc'], - }, - - overrides: [ - { - files: ['**/*.ts'], - // Allow ESLint to understand TypeScript syntax - parser: '@typescript-eslint/parser', - parserOptions: { - // The following option makes it possible to use rules that require type information - project: './tsconfig.eslint.json', - }, - // Allow ESLint to load rules from the TypeScript plugin - plugins: ['@typescript-eslint'], - extends: [ - // TypeScript plugin's recommended rules - 'plugin:@typescript-eslint/recommended', - - // AirBnB style guide (without React), modified for TypeScript rules: https://github.com/iamturns/eslint-config-airbnb-typescript. - 'airbnb-typescript/base', - ], - - rules: { - // TypeScript rules - // --- - // This level of this configuration contains rules which apply only to TypeScript. It also contains rules that - // are meant to override options or disable rules in the base configurations (there are no more base - // configurations in the subsequent overrides). - 'max-classes-per-file': 'off', - - // Disallow invocations of require(). This will help make imports more consistent and ensures a smoother - // transition to the best future syntax. And since this rule affects TypeScript, which is compiled, there's - // no reason we cannot adopt this syntax now. - // NOTE: The `@typescript-eslint/no-require-imports` rule can also achieve the same effect, but it is less - // configurable and only built to provide a migration path from TSLint. - 'import/no-commonjs': ['error', { - allowConditionalRequire: false, - }], - - // Don't verify that all named imports are part of the set of named exports for the referenced module. The - // TypeScript compiler will already perform this check, so it is redundant. - // NOTE: Consider contributing this to the `airbnb-typescript` config. - 'import/named': 'off', - 'node/no-missing-import': 'off', - - // Prefer an interface declaration over a type alias because interfaces can be extended, implemented, and merged - '@typescript-eslint/consistent-type-definitions': ['error', 'interface'], - - // Require class properties and methods to explicitly use accessibility modifiers (public, private, protected) - '@typescript-eslint/explicit-member-accessibility': 'error', - - // Forbids an object literal to appear in a type assertion expression unless its used as a parameter (we violate - // this rule for test code, to allow for looser property matching for objects - more in the test-specific rules - // section below). This allows the typechecker to perform validation on the value as an assignment, instead of - // allowing the type assertion to always win. - // Requires use of `as Type` instead of `` for type assertion. Consistency. - '@typescript-eslint/consistent-type-assertions': ['error', { - assertionStyle: 'as', - objectLiteralTypeAssertions: 'allow-as-parameter', - }], - - // Ensure that the values returned from a module are of the expected type - '@typescript-eslint/explicit-module-boundary-types': ['error', { - allowArgumentsExplicitlyTypedAsAny: true, - }], - - // Turns off all JSDoc plugin rules because they don't work consistently in TypeScript contexts. For example, - // it's not an error to export interfaces and types that don't have JSDoc on them without these contexts. Also, - // satisfying some of these rules would require redundant type information in the JSDoc comments, so its in - // conflict with the next rule. - // TODO: track progress on this issue https://github.com/gajus/eslint-plugin-jsdoc/issues/615 - ...jsDocRecommendedRulesOff, - - // No types in JSDoc for @param or @returns. TypeScript will provide this type information, so it would be - // redundant, and possibly conflicting. - 'jsdoc/no-types': 'error', - - // Allow use of import and export syntax, despite it not being supported in the node versions. Since this - // project is transpiled, the ignore option is used. Overrides node/recommended. - // 'node/no-unsupported-features/es-syntax': ['error', { ignores: ['modules'] }], - // TODO: The node plugin's ignore option doesn't work in order to suppress this error. - 'node/no-unsupported-features/es-syntax': 'off', - - // Allow safe references to functions before the declaration. Overrides AirBnB config. Not located in the - // override section below because a distinct override is necessary in JavaScript files. - 'no-use-before-define': 'off', - '@typescript-eslint/no-use-before-define': ['error', 'nofunc'], - // Turn off no-inferrable-types. While it may be obvious what the type of something is by its default - // value, being explicit is good, especially for newcomers. - '@typescript-eslint/no-inferrable-types': 'off', - - 'operator-linebreak': ['error', 'after', { overrides: { - '=': 'none' - }}], - }, - }, - { - files: ['**/*.js', '**/*.ts'], - rules: { - // Override rules - // --- - // This level of this configuration contains rules which override options or disable rules in the base - // configurations in both JavaScript and TypeScript. - - // Increase the max line length to 120. The rest of this setting is copied from the AirBnB config. - 'max-len': ['error', 120, 2, { - ignoreUrls: true, - ignoreComments: false, - ignoreRegExpLiterals: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - }], - - // Restrict the use of backticks to declare a normal string. Template literals should only be used when the - // template string contains placeholders. The rest of this setting is copied from the AirBnb config. - quotes: ['error', 'single', { avoidEscape: true, allowTemplateLiterals: false }], - - // the server side Slack API uses snake_case for parameters often - // for mocking and override support, we need to allow snake_case - // Allow leading underscores for parameter names, which is used to acknowledge unused variables in TypeScript. - // Also, enforce camelCase naming for variables. Ideally, the leading underscore could be restricted to only - // unused parameter names, but this rule isn't capable of knowing when a variable is unused. The camelcase and - // no-underscore-dangle rules are replaced with the naming-convention rule because this single rule can serve - // both purposes, and it works fine on non-TypeScript code. - camelcase: 'off', - 'no-underscore-dangle': 'off', - '@typescript-eslint/no-unused-vars': [ - 'error', - { - varsIgnorePattern: '^_', - argsIgnorePattern: '^_' - } - ], - '@typescript-eslint/naming-convention': [ - 'error', - { - selector: 'default', - format: ['camelCase'], - leadingUnderscore: 'allow', - }, - { - selector: 'variable', - // PascalCase for variables is added to allow exporting a singleton, function library, or bare object as in - // section 23.8 of the AirBnB style guide - format: ['camelCase', 'PascalCase', 'UPPER_CASE', 'snake_case'], - leadingUnderscore: 'allow', - }, - { - selector: 'parameter', - format: ['camelCase'], - leadingUnderscore: 'allow', - }, - { - selector: 'typeLike', - format: ['PascalCase', 'camelCase'], - leadingUnderscore: 'allow', - }, - { - selector: 'typeProperty', - format: ['snake_case', 'camelCase'], - }, - { - 'selector': 'objectLiteralProperty', - format: ['camelCase', 'snake_case', 'PascalCase'], - }, - { - selector: ['enumMember'], - format: ['PascalCase'], - }, - ], - - // Allow cyclical imports. Turning this rule on is mainly a way to manage the performance concern for linting - // time. Our projects are not large enough to warrant this. Overrides AirBnB styles. - 'import/no-cycle': 'off', - - // Prevent importing submodules of other modules. Using the internal structure of a module exposes - // implementation details that can potentially change in breaking ways. Overrides AirBnB styles. - 'import/no-internal-modules': ['error', { - // Use the following option to set a list of allowable globs in this project. - allow: [ - '**/middleware/*', // the src/middleware directory doesn't export a module, it's just a namespace. - '**/receivers/*', // the src/receivers directory doesn't export a module, it's just a namespace. - '**/types/**/*', - '**/types/*', // type hierarchies should be used however one wants - ], - }], - - // Remove the minProperties option for enforcing line breaks between braces. The AirBnB config sets this to 4, - // which is arbitrary and not backed by anything specific in the style guide. If we just remove it, we can - // rely on the max-len rule to determine if the line is too long and then enforce line breaks. Overrides AirBnB - // styles. - 'object-curly-newline': ['error', { multiline: true, consistent: true }], - - }, - }, - { - files: ['src/**/*.spec.ts'], - rules: { - // Test-specific rules - // --- - // Rules that only apply to Typescript _test_ source files - - // With Mocha as a test framework, it is sometimes helpful to assign - // shared state to Mocha's Context object, for example in setup and - // teardown test methods. Assigning stub/mock objects to the Context - // object via `this` is a common pattern in Mocha. As such, using - // `function` over the arrow notation binds `this` appropriately and - // should be used in tests. So: we turn off the prefer-arrow-callback - // rule. - // See https://github.com/slackapi/bolt-js/pull/1012#pullrequestreview-711232738 - // for a case of arrow-vs-function syntax coming up for the team - 'prefer-arrow-callback': 'off', - - // Unlike non-test-code, where we require use of `as Type` instead of `` for type assertion, - // in test code using the looser `as Type` syntax leads to easier test writing, since only required - // properties must be adhered to using the `as Type` syntax. - '@typescript-eslint/consistent-type-assertions': ['error', { - assertionStyle: 'as', - objectLiteralTypeAssertions: 'allow', - }], - // Using any types is so useful for mock objects, we are fine with disabling this rule - '@typescript-eslint/no-explicit-any': 'off', - // Some parts in Bolt (e.g., listener arguments) are unnecessarily optional. - // It's okay to omit this validation in tests. - '@typescript-eslint/no-non-null-assertion': 'off', - // Using ununamed functions (e.g., null logger) in tests is fine - 'func-names': 'off', - // In tests, don't force constructing a Symbol with a descriptor, as - // it's probably just for tests - 'symbol-description': 'off', - }, - }, - ], -}; - -// Test files globs -// '**/*.spec.ts', -// 'src/test-helpers.ts' diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index e882eb93f..eed7de872 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -16,7 +16,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [14.x, 16.x, 18.x, 20.x, 22.x] + node-version: [18.x, 20.x, 22.x] steps: - uses: actions/checkout@v4 @@ -25,9 +25,6 @@ jobs: with: node-version: ${{ matrix.node-version }} - run: npm install - - name: Print eslint version - run: ./node_modules/.bin/eslint -v - - run: npm run build - run: npm test - name: Upload coverage to Codecov if: matrix.node-version == '22.x' diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml index 024108ef3..9a6d34f0c 100644 --- a/.github/workflows/samples.yml +++ b/.github/workflows/samples.yml @@ -14,6 +14,7 @@ jobs: node-version: [18.x, 20.x, 22.x] example: - examples/getting-started-typescript + - examples/custom-receiver steps: - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 diff --git a/.nycrc.json b/.nycrc.json deleted file mode 100644 index bf6690f0a..000000000 --- a/.nycrc.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "**/*.spec.ts", - "src/test-helpers.ts" - ], - "reporter": ["lcov"], - "extension": [ - ".ts" - ], - "all": false, - "cache": true -} diff --git a/.vscode/launch.json b/.vscode/launch.json index f2805e79d..9b9055530 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,22 +10,14 @@ "name": "Spec tests", "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", "stopOnEntry": false, - "args": [ - "--config", - ".mocharc.json", - "--no-timeouts", - "src/*.spec.ts", - "src/**/*.spec.ts" - ], + "args": ["--config", ".mocharc.json", "--no-timeouts", "src/*.spec.ts", "src/**/*.spec.ts"], "cwd": "${workspaceFolder}", "runtimeExecutable": null, "env": { "NODE_ENV": "testing", "TS_NODE_PROJECT": "tsconfig.test.json" }, - "skipFiles": [ - "/**" - ] + "skipFiles": ["/**"] } ] } diff --git a/biome.json b/biome.json new file mode 100644 index 000000000..73f39e3c0 --- /dev/null +++ b/biome.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", + "files": { + "ignore": [ + "docs/_site", + "examples/**/dist" + ] + }, + "formatter": { + "enabled": true, + "formatWithErrors": false, + "ignore": [], + "attributePosition": "auto", + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 120, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true + } + }, + "organizeImports": { + "enabled": true + }, + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + } +} diff --git a/docs/.gitignore b/docs/.gitignore index 49ba8fa7c..7f541635e 100644 --- a/docs/.gitignore +++ b/docs/.gitignore @@ -2,4 +2,6 @@ node_modules/ .docusaurus .DS_Store build/ -.stylelintrc.json \ No newline at end of file +.stylelintrc.json +_site +Gemfile.lock diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 000000000..5d66e4afa --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,285 @@ +GEM + remote: https://rubygems.org/ + specs: + activesupport (6.0.4.7) + concurrent-ruby (~> 1.0, >= 1.0.2) + i18n (>= 0.7, < 2) + minitest (~> 5.1) + tzinfo (~> 1.1) + zeitwerk (~> 2.2, >= 2.2.2) + addressable (2.8.0) + public_suffix (>= 2.0.2, < 5.0) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.11.1) + colorator (1.1.0) + commonmarker (0.23.4) + concurrent-ruby (1.1.10) + dnsruby (1.61.9) + simpleidn (~> 0.1) + dotenv (2.7.6) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + ethon (0.15.0) + ffi (>= 1.15.0) + eventmachine (1.2.7) + execjs (2.8.1) + faraday (1.10.0) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.0.3) + multipart-post (>= 1.2, < 3) + faraday-net_http (1.0.1) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + ffi (1.15.5) + forwardable-extended (2.6.0) + gemoji (3.0.1) + github-pages (225) + github-pages-health-check (= 1.17.9) + jekyll (= 3.9.0) + jekyll-avatar (= 0.7.0) + jekyll-coffeescript (= 1.1.1) + jekyll-commonmark-ghpages (= 0.2.0) + jekyll-default-layout (= 0.1.4) + jekyll-feed (= 0.15.1) + jekyll-gist (= 1.5.0) + jekyll-github-metadata (= 2.13.0) + jekyll-include-cache (= 0.2.1) + jekyll-mentions (= 1.6.0) + jekyll-optional-front-matter (= 0.3.2) + jekyll-paginate (= 1.1.0) + jekyll-readme-index (= 0.3.0) + jekyll-redirect-from (= 0.16.0) + jekyll-relative-links (= 0.6.1) + jekyll-remote-theme (= 0.4.3) + jekyll-sass-converter (= 1.5.2) + jekyll-seo-tag (= 2.8.0) + jekyll-sitemap (= 1.4.0) + jekyll-swiss (= 1.0.0) + jekyll-theme-architect (= 0.2.0) + jekyll-theme-cayman (= 0.2.0) + jekyll-theme-dinky (= 0.2.0) + jekyll-theme-hacker (= 0.2.0) + jekyll-theme-leap-day (= 0.2.0) + jekyll-theme-merlot (= 0.2.0) + jekyll-theme-midnight (= 0.2.0) + jekyll-theme-minimal (= 0.2.0) + jekyll-theme-modernist (= 0.2.0) + jekyll-theme-primer (= 0.6.0) + jekyll-theme-slate (= 0.2.0) + jekyll-theme-tactile (= 0.2.0) + jekyll-theme-time-machine (= 0.2.0) + jekyll-titles-from-headings (= 0.5.3) + jemoji (= 0.12.0) + kramdown (= 2.3.1) + kramdown-parser-gfm (= 1.1.0) + liquid (= 4.0.3) + mercenary (~> 0.3) + minima (= 2.5.1) + nokogiri (>= 1.12.5, < 2.0) + rouge (= 3.26.0) + terminal-table (~> 1.4) + github-pages-health-check (1.17.9) + addressable (~> 2.3) + dnsruby (~> 1.60) + octokit (~> 4.0) + public_suffix (>= 3.0, < 5.0) + typhoeus (~> 1.3) + html-pipeline (2.14.1) + activesupport (>= 2) + nokogiri (>= 1.4) + http_parser.rb (0.8.0) + i18n (0.9.5) + concurrent-ruby (~> 1.0) + jekyll (3.9.0) + addressable (~> 2.4) + colorator (~> 1.0) + em-websocket (~> 0.5) + i18n (~> 0.7) + jekyll-sass-converter (~> 1.0) + jekyll-watch (~> 2.0) + kramdown (>= 1.17, < 3) + liquid (~> 4.0) + mercenary (~> 0.3.3) + pathutil (~> 0.9) + rouge (>= 1.7, < 4) + safe_yaml (~> 1.0) + jekyll-avatar (0.7.0) + jekyll (>= 3.0, < 5.0) + jekyll-coffeescript (1.1.1) + coffee-script (~> 2.2) + coffee-script-source (~> 1.11.1) + jekyll-commonmark (1.4.0) + commonmarker (~> 0.22) + jekyll-commonmark-ghpages (0.2.0) + commonmarker (~> 0.23.4) + jekyll (~> 3.9.0) + jekyll-commonmark (~> 1.4.0) + rouge (>= 2.0, < 4.0) + jekyll-default-layout (0.1.4) + jekyll (~> 3.0) + jekyll-feed (0.15.1) + jekyll (>= 3.7, < 5.0) + jekyll-gist (1.5.0) + octokit (~> 4.2) + jekyll-github-metadata (2.13.0) + jekyll (>= 3.4, < 5.0) + octokit (~> 4.0, != 4.4.0) + jekyll-include-cache (0.2.1) + jekyll (>= 3.7, < 5.0) + jekyll-mentions (1.6.0) + html-pipeline (~> 2.3) + jekyll (>= 3.7, < 5.0) + jekyll-optional-front-matter (0.3.2) + jekyll (>= 3.0, < 5.0) + jekyll-paginate (1.1.0) + jekyll-readme-index (0.3.0) + jekyll (>= 3.0, < 5.0) + jekyll-redirect-from (0.16.0) + jekyll (>= 3.3, < 5.0) + jekyll-relative-links (0.6.1) + jekyll (>= 3.3, < 5.0) + jekyll-remote-theme (0.4.3) + addressable (~> 2.0) + jekyll (>= 3.5, < 5.0) + jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) + rubyzip (>= 1.3.0, < 3.0) + jekyll-sass-converter (1.5.2) + sass (~> 3.4) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-sitemap (1.4.0) + jekyll (>= 3.7, < 5.0) + jekyll-swiss (1.0.0) + jekyll-theme-architect (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-cayman (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-dinky (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-hacker (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-leap-day (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-merlot (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-midnight (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-minimal (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-modernist (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-primer (0.6.0) + jekyll (> 3.5, < 5.0) + jekyll-github-metadata (~> 2.9) + jekyll-seo-tag (~> 2.0) + jekyll-theme-slate (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-tactile (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-theme-time-machine (0.2.0) + jekyll (> 3.5, < 5.0) + jekyll-seo-tag (~> 2.0) + jekyll-titles-from-headings (0.5.3) + jekyll (>= 3.3, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + jemoji (0.12.0) + gemoji (~> 3.0) + html-pipeline (~> 2.2) + jekyll (>= 3.0, < 5.0) + kramdown (2.3.1) + rexml + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.3) + listen (3.7.1) + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + mercenary (0.3.6) + mini_portile2 (2.8.0) + minima (2.5.1) + jekyll (>= 3.5, < 5.0) + jekyll-feed (~> 0.9) + jekyll-seo-tag (~> 2.1) + minitest (5.15.0) + multipart-post (2.1.1) + nokogiri (1.13.4) + mini_portile2 (~> 2.8.0) + racc (~> 1.4) + octokit (4.22.0) + faraday (>= 0.9) + sawyer (~> 0.8.0, >= 0.5.3) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (4.0.7) + racc (1.6.0) + rb-fsevent (0.11.1) + rb-inotify (0.10.1) + ffi (~> 1.0) + rexml (3.2.5) + rouge (3.26.0) + ruby2_keywords (0.0.5) + rubyzip (2.3.2) + safe_yaml (1.0.5) + sass (3.7.4) + sass-listen (~> 4.0.0) + sass-listen (4.0.0) + rb-fsevent (~> 0.9, >= 0.9.4) + rb-inotify (~> 0.9, >= 0.9.7) + sawyer (0.8.2) + addressable (>= 2.3.5) + faraday (> 0.8, < 2.0) + simpleidn (0.2.1) + unf (~> 0.1.4) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) + thread_safe (0.3.6) + typhoeus (1.4.0) + ethon (>= 0.9.0) + tzinfo (1.2.9) + thread_safe (~> 0.1) + unf (0.1.4) + unf_ext + unf_ext (0.0.8.1) + unicode-display_width (1.8.0) + zeitwerk (2.5.4) + +PLATFORMS + ruby + +DEPENDENCIES + dotenv + github-pages + +BUNDLED WITH + 2.1.4 diff --git a/docs/content/tutorial/migration-v4.md b/docs/content/tutorial/migration-v4.md new file mode 100644 index 000000000..75097bf84 --- /dev/null +++ b/docs/content/tutorial/migration-v4.md @@ -0,0 +1,155 @@ +--- +title: Migrating to V4 +slug: migration-v4 +lang: en +--- + +This guide will walk you through the process of updating your app from using `@slack/bolt@3.x` to `@slack/bolt@4.x`. There may be a few changes you'll need to make depending on which features you use, but for most apps, these changes can be applied in a few minutes. Some apps may need no changes at all. + +--- + +## ๐Ÿšจ Breaking Changes + +- โฌ†๏ธ [Support for node v14 and v16 have been officially dropped, as these node versions have been EOL'ed](#minimum-node-version). +- ๐Ÿšฅ [`*MiddlewareArgs` interfaces, modeling middleware arguments for different _kinds_ of Slack event payloads processed by bolt apps, have been improved](#middleware-arg-types). +- ๐ŸŒ [The Web API client, `@slack/web-api`, has been upgraded from v6 to v7](#web-api-v7). +- ๐Ÿ”Œ [The Socket Mode handler package, `@slack/socket-mode`, has been upgraded from v1 to v2](#socket-mode-v2). +- ๐Ÿš… For those using the `ExpressReceiver`, [`express` has been upgraded from v4 to v5](#express-v5). +- ๐Ÿฝ๏ธ [The `@slack/types` package is no longer exported without a namespace; it is now exported under the named `types` export](#types-named-export). +- ๐Ÿง˜ [The `SocketModeFunctions` class with a single static method instead now directly exports the single `defaultProcessEventErrorHandler` method from it](#socketmodefunctions). +- ๐Ÿญ [Some of the built-in middleware functions like `ignoreSelf` and `directMention` have had their APIs changed to create a consistent middleware style](#built-in-middleware-changes). +- ๐ŸŒฉ๏ธ [The `AwsEvent` interface has changed](#awsevent-changes). +- ๐Ÿงน [Deprecated methods, modules and properties in v3 were removed](#removed-deprecations). + +## โœจ Other Changes + +- ๐Ÿšณ [Steps From Apps related types, methods and constants were marked as deprecated](#sfa-deprecation). +- ๐Ÿ“ฆ [The `@slack/web-api` package leveraged within bolt-js is now exported under the `webApi` namespace](#web-api-export). + +## Details + +### โฌ†๏ธ Minimum Node version {#minimum-node-version} + +`@slack/bolt@4.x` requires a minimum Node version of `18` and minimum npm version of `8.6.0` . + +### ๐Ÿšฅ Changes to middleware argument types {#middleware-arg-types} + +This change primarily applies to TypeScript users. + +Many middleware argument types, for example [the `SlackEventMiddlewareArgs` type](https://github.com/slackapi/bolt-js/blob/%40slack/bolt%403.22.0/src/types/events/index.ts#L11-L19), previously used a conditional to sometimes define particular additional helper utilities on the middleware arguments. For example, [the `say`](https://github.com/slackapi/bolt-js/blob/%40slack/bolt%403.22.0/src/types/actions/index.ts#L47) utility, or [tacking on a convenience `message` property](https://github.com/slackapi/bolt-js/blob/%40slack/bolt%403.22.0/src/types/events/index.ts#L14) for message-event-related payloads. This was problematic: when the payload was not of a type that required the extra utility, these properties would be required to exist on the middleware arguments but have a type of `undefined`. Those of us trying to build generic middleware utilities would have to deal with TypeScript compilation errors and needing to liberally type-cast to avoid these conditional mismatches with `undefined`. + +Instead, these `MiddlewareArgs` types now conditionally create a type intersection when appropriate in order to provide this conditional-utility-extension mechanism. That looks something like: + +```typescript +type SomeMiddlewareArgs = { + // some type in here +} & (EventType extends 'message' + // If this is a message event, add a `message` property + ? { message: EventFromType } + : unknown +) +``` + +With the above, now when a message payload is wrapped in middleware arguments, it will contain an appropriate `message` property, whereas a non-message payload will be intersected with `unknown` - effectively a type "noop." No more e.g. `say: undefined` or `message: undefined` to deal with! + +### ๐ŸŒ `@slack/web-api` v7 upgrade {#web-api-v7} + +All bolt handlers are [provided a convenience `client` argument](../concepts/web-api) that developers can use to make API requests to [Slack's public HTTP APIs][methods]. This `client` is powered by [the `@slack/web-api` package][web-api]. In bolt v4, `web-api` has been upgraded from v6 to v7. + +More APIs! Better argument type safety! And a whole slew of other changes, too. Many of these changes won't affect JavaScript application builders, but if you are building a bolt app using TypeScript, you may see some compilation issues. Head over to [the `@slack/web-api` v6 -> v7 migration guide](https://github.com/slackapi/node-slack-sdk/wiki/Migration-Guide-for-web%E2%80%90api-v7) to get the details on what changed and how to migrate to v7. + +### ๐Ÿ”Œ `@slack/socket-mode` v2 upgrade {#socket-mode-v2} + +While the breaking changes from this upgrade should be shielded from most bolt-js users, if you are using [the `SocketModeReceiver` or setting `socketMode: true`](../concepts/socket-mode) _and_ attach custom code to how the `SocketModeReceiver` operates, we suggest you read through [the `@slack/socket-mode` v1 -> v2 migration guide](https://github.com/slackapi/node-slack-sdk/wiki/Migration-Guide-for-socket%E2%80%90mode-2.0), just in case. + +### ๐Ÿš… `express` v5 upgrade {#express-v5} + +For those building bolt-js apps using the `ExpressReceiver`, the packaged `express` version has been upgraded to v5. Best to check [the list of breaking changes in `express` v5](https://github.com/expressjs/express/blob/5.x/History.md#500--2024-09-10) and keep tabs on [express#5944](https://github.com/expressjs/express/issues/5944), which tracks the creation of an `express` v4 -> v5 migration guide. + +### ๐Ÿฝ๏ธ `@slack/types` exported as a named `types` export {#types-named-export} + +We are slowly moving more core Slack domain object types and interfaces into [the utility package `@slack/types`][types]. For example, recently we shuffled [Slack Events API payloads](https://api.slack.com/events) from bolt-js over to `@slack/types`. Similar moves will continue as we improve bolt-js. Ideally, we'd like for everyone - ourselves as Slack employees but of course you as well, dear developer - to leverage these types when modeling Slack domain objects. + +Anyways, previously we simply `export * from '@slack/types';` in bolt-js. We've tweaked this somewhat, it is now: `export * as types from '@slack/types';`. So if you are using `@slack/types` when packaged within bolt-js, please update your references to something like: + +```typescript +import { App, type types } from '@slack/bolt'; + +// Now you can get references to e.g. `types.BotMessageEvent` +``` + +### ๐Ÿง˜ `SocketModeFunctions` class disassembled {#socketmodefunctions} + +If you previously imported the `SocketModeFunctions` class, you likely only did so to get a reference to the single static method available on this class: [`defaultProcessEventErrorHandler`](https://github.com/slackapi/bolt-js/blob/cd662ed540aa40b5cf20b4d5c21b0008db8ed427/src/receivers/SocketModeFunctions.ts#L13). Instead, you can now directly import the named `defaultProcessEventErrorHandler` export instead: + +```typescript +// before: +import { SocketModeFunctions } from '@slack/bolt'; +// you probably did something with: +SocketModeFunctions.defaultProcessEventErrorHandler + +// now: +import { defaultProcessEventHandler } from '@slack/bolt'; +``` + +### ๐Ÿญ Built-in middleware changes {#built-in-middleware-changes} + +Two [built-in middlewares](../reference#built-in-listener-middleware-functions), `ignoreSelf` and `directMention`, previously needed to be invoked as a function in order to _return_ a middleware. These two built-in middlewares were not parameterized in the sense that they should just be used directly; as a result, you no longer should invoke them and instead pass them directly. + +As an example, previously you may have leveraged `directMention` like this: + +```typescript +app.message(directMention(), async (args) => { + // my handler here +}); +``` + +Instead, you should now use it like so: + +```typescript +app.message(directMention, async (args) => { + // my handler here +}); +``` + +### ๐ŸŒฉ๏ธ `AwsEvent` interface changes {#awsevent-changes} + +For users of the `AwsLambdaReceiver` and TypeScript, [we previously modeled, rather simplistically, the AWS event payloads](https://github.com/slackapi/bolt-js/blob/cd662ed540aa40b5cf20b4d5c21b0008db8ed427/src/receivers/AwsLambdaReceiver.ts#L11-L24): liberal use of `any` and in certain cases, incorrect property types altogether. We've now improved these to be more accurate and to take into account the two versions of API Gateway payloads that AWS supports (v1 and v2). Details for these changes are available in [#2277](https://github.com/slackapi/bolt-js/pull/2277). + +As for userland changes that may be required, this depends on your use of the `AwsEvent` interface. The major change here is that it is a union type of V1 and V2 payload structures. Check out the source code and changes in [#2277](https://github.com/slackapi/bolt-js/pull/2277) for details on what each payload version structure looks like and how to adapt your application code to account for these differences. Most likely, your code will need to test for the existence of certain properties in order for TypeScript to narrow down to the appropriate payload version. For example, one change bolt-js had to employ in its code as a result of this more correct typing is the following: + +```typescript +// the variable `awsEvent` is of type `AwsEvent` +let path: string; +if ('path' in awsEvent) { + // This is a v1 payload, so `awsEvent.path` exists and points to the request URL path. + path = awsEvent.path; +} else { + // This is a v2 payload, so `awsEvent.rawPath` exists and points to the request URL path. + path = awsEvent.rawPath; +} +this.logger.info(`No request handler matched the request: ${path}`); +``` + +### ๐Ÿงน Removed deprecations {#removed-deprecations} + +- The deprecated type `KnownKeys` was removed. Admittedly, it wasn't very useful: `export type KnownKeys<_T> = never;` +- The deprecated types `VerifyOptions` and `OptionsRequest` were removed. +- The deprecated methods `extractRetryNum`, `extractRetryReason`, `defaultRenderHtmlForInstallPath`, `renderHtmlForInstallPath` and `verify` were removed. + +### ๐Ÿšณ Steps From Apps related deprecations {#sfa-deprecation} + +A variety of methods, constants and types related to Steps From Apps were deprecated and will be removed in bolt-js v5. + +### ๐Ÿ“ฆ `@slack/web-api` exported as `webApi` {#web-api-export} + +To help application developers keep versions of various `@slack/*` dependencies in sync with those used by bolt-js, `@slack/web-api` is now exported from bolt-js under the `webApi` export. Unless applications have specific version needs from the `@slack/web-api` package, apps should be able to import `web-api` from bolt instead: + +```typescript +import { webApi } from '@slack/bolt'; +// now can use e.g. webApi.WebClient, etc. +``` + +[methods]: https://api.slack.com/methods +[web-api]: https://www.npmjs.com/package/@slack/web-api +[types]: https://www.npmjs.com/package/@slack/types diff --git a/docs/docusaurus.config.js b/docs/docusaurus.config.js index 0a2058be9..81c195376 100644 --- a/docs/docusaurus.config.js +++ b/docs/docusaurus.config.js @@ -4,7 +4,7 @@ // There are various equivalent ways to declare your Docusaurus config. // See: https://docusaurus.io/docs/api/docusaurus-config -import {themes as prismThemes} from 'prism-react-renderer'; +import { themes as prismThemes } from 'prism-react-renderer'; /** @type {import('@docusaurus/types').Config} */ const config = { @@ -23,7 +23,7 @@ const config = { i18n: { defaultLocale: 'en', - locales: ['en','ja-jp'], + locales: ['en', 'ja-jp'], }, presets: [ @@ -49,26 +49,24 @@ const config = { ], ], -plugins: -['docusaurus-theme-github-codeblock', - [ - '@docusaurus/plugin-client-redirects', - { - redirects: [ - { - to: '/getting-started', - from: ['/tutorial/getting-started'], - }, - { - to: '/', - from: ['/concepts','/concepts/advanced','/concepts/basic'], - }, - ], - }, + plugins: [ + 'docusaurus-theme-github-codeblock', + [ + '@docusaurus/plugin-client-redirects', + { + redirects: [ + { + to: '/getting-started', + from: ['/tutorial/getting-started'], + }, + { + to: '/', + from: ['/concepts', '/concepts/advanced', '/concepts/basic'], + }, + ], + }, + ], ], - -], - themeConfig: /** @type {import('@docusaurus/preset-classic').ThemeConfig} */ @@ -82,12 +80,12 @@ plugins: }, }, navbar: { - title: "Slack Developer Tools", + title: 'Slack Developer Tools', logo: { alt: 'Slack logo', src: 'img/slack-logo.svg', href: 'https://tools.slack.dev', - target : '_self' + target: '_self', }, items: [ { @@ -110,7 +108,7 @@ plugins: to: 'https://tools.slack.dev/bolt-python', target: '_self', }, - ] + ], }, { type: 'dropdown', @@ -137,7 +135,7 @@ plugins: to: 'https://api.slack.com/automation/quickstart', target: '_self', }, - ] + ], }, { type: 'dropdown', @@ -154,7 +152,7 @@ plugins: to: 'https://slackcommunity.com/', target: '_self', }, - ] + ], }, { to: 'https://api.slack.com/docs', @@ -167,25 +165,25 @@ plugins: }, { 'aria-label': 'GitHub Repository', - 'className': 'navbar-github-link', - 'href': 'https://github.com/slackapi/bolt-js', - 'position': 'right', + className: 'navbar-github-link', + href: 'https://github.com/slackapi/bolt-js', + position: 'right', target: '_self', }, ], }, footer: { - copyright: `

Made with โ™ก by Slack and pals like you

`, + copyright: '

Made with โ™ก by Slack and pals like you

', }, prism: { // switch to alucard when available in prism? - theme: prismThemes.github, + theme: prismThemes.github, darkTheme: prismThemes.dracula, }, - codeblock: { - showGithubLink: true, - githubLinkLabel: 'View on GitHub', - }, + codeblock: { + showGithubLink: true, + githubLinkLabel: 'View on GitHub', + }, // announcementBar: { // id: `announcementBar`, // content: `๐ŸŽ‰๏ธ Version 2.26.0 of the developer tools for the Slack automations platform is here! ๐ŸŽ‰๏ธ `, diff --git a/docs/package.json b/docs/package.json index fa1cd46e7..0d07ca10a 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,18 +32,10 @@ "stylelint-config-standard": "^36.0.1" }, "browserslist": { - "production": [ - ">0.5%", - "not dead", - "not op_mini all" - ], - "development": [ - "last 3 chrome version", - "last 3 firefox version", - "last 5 safari version" - ] + "production": [">0.5%", "not dead", "not op_mini all"], + "development": ["last 3 chrome version", "last 3 firefox version", "last 5 safari version"] }, "engines": { "node": ">=20.0" } -} \ No newline at end of file +} diff --git a/docs/sidebars.js b/docs/sidebars.js index 54625e6f4..15ae96e45 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -39,7 +39,7 @@ const sidebars = { 'basic/custom-steps', 'basic/options', 'basic/authenticating-oauth', - 'basic/socket-mode' + 'basic/socket-mode', ], }, { @@ -62,10 +62,7 @@ const sidebars = { { type: 'category', label: 'Deployments', - items: [ - 'deployments/aws-lambda', - 'deployments/heroku' - ], + items: ['deployments/aws-lambda', 'deployments/heroku'], }, { type: 'category', @@ -86,7 +83,8 @@ const sidebars = { 'tutorial/getting-started-http', 'tutorial/hubot-migration', 'tutorial/migration-v2', - 'tutorial/migration-v3' + 'tutorial/migration-v3', + 'tutorial/migration-v4', ], }, { type: 'html', value: '


' }, @@ -107,7 +105,6 @@ const sidebars = { label: 'Contributors Guide', href: 'https://github.com/SlackAPI/bolt-js/blob/main/.github/contributing.md', }, - ], }; diff --git a/docs/src/theme/NotFound/Content/index.js b/docs/src/theme/NotFound/Content/index.js index c122bc039..fbd861df6 100644 --- a/docs/src/theme/NotFound/Content/index.js +++ b/docs/src/theme/NotFound/Content/index.js @@ -1,32 +1,26 @@ -import React from 'react'; -import clsx from 'clsx'; import Translate from '@docusaurus/Translate'; import Heading from '@theme/Heading'; -export default function NotFoundContent({className}) { +import clsx from 'clsx'; +import React from 'react'; +export default function NotFoundContent({ className }) { return (
- + Oh no! There's nothing here.

- - If we've led you astray, please let us know. We'll do our best to get things in order. - + + If we've led you astray, please let us know. We'll do our best to get things in order.

- - For now, we suggest heading back to the beginning to get your bearings. May your next journey have clear skies to guide you true. + + For now, we suggest heading back to the beginning to get your bearings. May your next journey have clear + skies to guide you true.

diff --git a/docs/src/theme/NotFound/index.js b/docs/src/theme/NotFound/index.js index 3b551f9e4..7c82b024f 100644 --- a/docs/src/theme/NotFound/index.js +++ b/docs/src/theme/NotFound/index.js @@ -1,8 +1,8 @@ -import React from 'react'; -import {translate} from '@docusaurus/Translate'; -import {PageMetadata} from '@docusaurus/theme-common'; +import { translate } from '@docusaurus/Translate'; +import { PageMetadata } from '@docusaurus/theme-common'; import Layout from '@theme/Layout'; import NotFoundContent from '@theme/NotFound/Content'; +import React from 'react'; export default function Index() { const title = translate({ id: 'theme.NotFound.title', diff --git a/examples/custom-properties/express.js b/examples/custom-properties/express.js index 7ab3138f2..163b70b87 100644 --- a/examples/custom-properties/express.js +++ b/examples/custom-properties/express.js @@ -6,10 +6,10 @@ const app = new App({ signingSecret: process.env.SLACK_SIGNING_SECRET, customPropertiesExtractor: (req) => { return { - "headers": req.headers, - "foo": "bar", + headers: req.headers, + foo: 'bar', }; - } + }, }), }); @@ -23,4 +23,4 @@ app.use(async ({ logger, context, next }) => { await app.start(process.env.PORT || 3000); console.log('โšก๏ธ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/custom-properties/http.js b/examples/custom-properties/http.js index 0e6e9ba8b..16bb79a93 100644 --- a/examples/custom-properties/http.js +++ b/examples/custom-properties/http.js @@ -6,15 +6,15 @@ const app = new App({ signingSecret: process.env.SLACK_SIGNING_SECRET, customPropertiesExtractor: (req) => { return { - "headers": req.headers, - "foo": "bar", + headers: req.headers, + foo: 'bar', }; }, // other custom handlers dispatchErrorHandler: ({ error, logger, response }) => { logger.error(`dispatch error: ${error}`); response.writeHead(404); - response.write("Something is wrong!"); + response.write('Something is wrong!'); response.end(); }, processEventErrorHandler: ({ error, logger, response }) => { @@ -51,4 +51,4 @@ app.use(async ({ logger, context, next }) => { process.exit(255); } console.log('โšก๏ธ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/custom-properties/package.json b/examples/custom-properties/package.json index ec2af1efa..f38030765 100644 --- a/examples/custom-properties/package.json +++ b/examples/custom-properties/package.json @@ -10,6 +10,6 @@ }, "license": "MIT", "dependencies": { - "@slack/socket-mode": "^1.2.0" + "@slack/bolt": "^3" } } diff --git a/examples/custom-properties/socket-mode.js b/examples/custom-properties/socket-mode.js index be822416f..345a50edf 100644 --- a/examples/custom-properties/socket-mode.js +++ b/examples/custom-properties/socket-mode.js @@ -6,11 +6,11 @@ const app = new App({ appToken: process.env.SLACK_APP_TOKEN, customPropertiesExtractor: ({ type, body }) => { return { - "socket_mode_payload_type": type, - "socket_mode_payload": body, - "foo": "bar", + socket_mode_payload_type: type, + socket_mode_payload: body, + foo: 'bar', }; - } + }, }), }); @@ -24,4 +24,4 @@ app.use(async ({ logger, context, next }) => { await app.start(); console.log('โšก๏ธ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/custom-receiver/package.json b/examples/custom-receiver/package.json index 6507886c7..f087771f5 100644 --- a/examples/custom-receiver/package.json +++ b/examples/custom-receiver/package.json @@ -4,25 +4,25 @@ "description": "Example app using OAuth", "main": "app.js", "scripts": { - "lint": "eslint --fix --ext .ts src", - "build": "npm run lint && tsc -p .", - "build:watch": "npm run lint && tsc -w -p .", + "build": "tsc -p .", + "build:watch": "tsc -w -p .", "koa": "npm run build && node dist/koa-main.js", "fastify": "npm run build && node dist/fastify-main.js" }, "license": "MIT", "dependencies": { - "@koa/router": "^10.1.1", - "@slack/logger": "^3.0.0", - "@slack/oauth": "^2.5.0", - "dotenv": "^8.2.0", - "fastify": "^3.27.4", - "koa": "^2.13.4" + "@koa/router": "^13", + "@slack/bolt": "^3", + "@slack/logger": "^4.0.0", + "@slack/oauth": "^3", + "dotenv": "^16", + "fastify": "^5", + "koa": "^2" }, "devDependencies": { - "@types/koa__router": "^8.0.11", - "@types/node": "^14.14.35", - "ts-node": "^9.1.1", - "typescript": "^4.2.3" + "@types/koa__router": "^12", + "@types/node": "^18", + "ts-node": "^10", + "typescript": "5.3.3" } } diff --git a/examples/custom-receiver/src/FastifyReceiver.ts b/examples/custom-receiver/src/FastifyReceiver.ts index 1bfffda93..4ae2e647e 100644 --- a/examples/custom-receiver/src/FastifyReceiver.ts +++ b/examples/custom-receiver/src/FastifyReceiver.ts @@ -1,23 +1,26 @@ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ -import { InstallProvider, CallbackOptions, InstallPathOptions } from '@slack/oauth'; -import { ConsoleLogger, LogLevel, Logger } from '@slack/logger'; -import Fastify, { FastifyInstance } from 'fastify'; -import { Server } from 'http'; +import type { Server } from 'node:http'; import { - App, - CodedError, - Receiver, - ReceiverEvent, + type App, + type BufferedIncomingMessage, + type CodedError, + HTTPResponseAck, + type InstallProviderOptions, + type InstallURLOptions, + type Receiver, + type ReceiverEvent, ReceiverInconsistentStateError, + type ReceiverProcessEventErrorHandlerArgs, + type ReceiverUnhandledRequestHandlerArgs, HTTPModuleFunctions as httpFunc, - HTTPResponseAck, - InstallProviderOptions, - InstallURLOptions, - BufferedIncomingMessage, - ReceiverProcessEventErrorHandlerArgs, - ReceiverUnhandledRequestHandlerArgs, } from '@slack/bolt'; +import { ConsoleLogger, type LogLevel, type Logger } from '@slack/logger'; +import { type CallbackOptions, type InstallPathOptions, InstallProvider } from '@slack/oauth'; +import Fastify, { type FastifyInstance } from 'fastify'; + +type CustomPropertiesExtractor = ( + request: BufferedIncomingMessage, + // biome-ignore lint/suspicious/noExplicitAny: custom properties can be anything +) => Record; export interface InstallerOptions { stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore @@ -50,13 +53,8 @@ export interface FastifyReceiverOptions { scopes?: InstallURLOptions['scopes']; installerOptions?: InstallerOptions; fastify?: FastifyInstance; - customPropertiesExtractor?: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; - processEventErrorHandler?: ( - args: ReceiverProcessEventErrorHandlerArgs - ) => Promise; + customPropertiesExtractor?: CustomPropertiesExtractor; + processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; // NOTE: As we use setTimeout under the hood, this cannot be async unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; @@ -77,10 +75,7 @@ export default class FastifyReceiver implements Receiver { private unhandledRequestTimeoutMillis: number; - private customPropertiesExtractor: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; + private customPropertiesExtractor: CustomPropertiesExtractor; private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; @@ -99,12 +94,12 @@ export default class FastifyReceiver implements Receiver { public constructor(options: FastifyReceiverOptions) { this.signatureVerification = options.signatureVerification ?? true; this.signingSecretProvider = options.signingSecret; - this.customPropertiesExtractor = options.customPropertiesExtractor !== undefined ? - options.customPropertiesExtractor : - (_) => ({}); + this.customPropertiesExtractor = + options.customPropertiesExtractor !== undefined ? options.customPropertiesExtractor : (_) => ({}); this.path = options.path ?? '/slack/events'; this.unhandledRequestTimeoutMillis = options.unhandledRequestTimeoutMillis ?? 3001; - this.logger = options.logger ?? + this.logger = + options.logger ?? (() => { const defaultLogger = new ConsoleLogger(); if (options.logLevel) { @@ -115,7 +110,9 @@ export default class FastifyReceiver implements Receiver { this.fastify = options.fastify ?? Fastify({ logger: true }); if (options.fastify) { - this.logger.info('This Receiver replaces content type parsers in the given fastify instance. Other POST endpoints may no longer work as you expect.'); + this.logger.info( + 'This Receiver replaces content type parsers in the given fastify instance. Other POST endpoints may no longer work as you expect.', + ); } // To do the request signature validation, bolt-js needs access to the as-is text request body const contentTypes = ['application/json', 'application/x-www-form-urlencoded']; @@ -127,16 +124,10 @@ export default class FastifyReceiver implements Receiver { this.unhandledRequestHandler = options.unhandledRequestHandler ?? httpFunc.defaultUnhandledRequestHandler; this.installerOptions = options.installerOptions; - if ( - this.installerOptions && - this.installerOptions.installPath === undefined - ) { + if (this.installerOptions && this.installerOptions.installPath === undefined) { this.installerOptions.installPath = '/slack/install'; } - if ( - this.installerOptions && - this.installerOptions.redirectUriPath === undefined - ) { + if (this.installerOptions && this.installerOptions.redirectUriPath === undefined) { this.installerOptions.redirectUriPath = '/slack/oauth_redirect'; } if (options.clientId && options.clientSecret) { @@ -162,9 +153,10 @@ export default class FastifyReceiver implements Receiver { private async signingSecret(): Promise { if (this._signingSecret === undefined) { - this._signingSecret = typeof this.signingSecretProvider === 'string' ? - this.signingSecretProvider : - await this.signingSecretProvider(); + this._signingSecret = + typeof this.signingSecretProvider === 'string' + ? this.signingSecretProvider + : await this.signingSecretProvider(); } return this._signingSecret; } @@ -178,18 +170,10 @@ export default class FastifyReceiver implements Receiver { this.installerOptions.redirectUriPath ) { this.fastify.get(this.installerOptions.installPath, async (req, res) => { - await this.installer?.handleInstallPath( - req.raw, - res.raw, - this.installerOptions?.installPathOptions, - ); + await this.installer?.handleInstallPath(req.raw, res.raw, this.installerOptions?.installPathOptions); }); this.fastify.get(this.installerOptions.redirectUriPath, async (req, res) => { - await this.installer?.handleCallback( - req.raw, - res.raw, - this.installerOptions?.callbackOptions, - ); + await this.installer?.handleCallback(req.raw, res.raw, this.installerOptions?.callbackOptions); }); } @@ -197,7 +181,7 @@ export default class FastifyReceiver implements Receiver { const req = request.raw; const res = response.raw; - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything (req as any).rawBody = Buffer.from(request.body as string); // Verify authenticity let bufferedReq: BufferedIncomingMessage; @@ -211,8 +195,7 @@ export default class FastifyReceiver implements Receiver { req, ); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; if (this.signatureVerification) { this.logger.warn(`Failed to parse and verify the request data: ${e.message}`); } else { @@ -223,13 +206,12 @@ export default class FastifyReceiver implements Receiver { } // Parse request body - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything let body: any; try { body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; this.logger.warn(`Malformed request body: ${e.message}`); httpFunc.buildNoBodyResponse(res, 400); return; @@ -289,7 +271,7 @@ export default class FastifyReceiver implements Receiver { }); } - public start(port: number = 3000): Promise { + public start(port = 3000): Promise { if (this.server !== undefined) { return Promise.reject( new ReceiverInconsistentStateError('The receiver cannot be started because it was already started.'), diff --git a/examples/custom-receiver/src/KoaReceiver.ts b/examples/custom-receiver/src/KoaReceiver.ts index 692e7ce87..5dfeee8ef 100644 --- a/examples/custom-receiver/src/KoaReceiver.ts +++ b/examples/custom-receiver/src/KoaReceiver.ts @@ -1,24 +1,22 @@ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ -import { InstallProvider, CallbackOptions, InstallPathOptions } from '@slack/oauth'; -import { ConsoleLogger, LogLevel, Logger } from '@slack/logger'; +import { type Server, createServer } from 'node:http'; import Router from '@koa/router'; -import Koa from 'koa'; -import { Server, createServer } from 'http'; import { - App, - CodedError, - Receiver, - ReceiverEvent, + type App, + type BufferedIncomingMessage, + type CodedError, + HTTPResponseAck, + type InstallProviderOptions, + type InstallURLOptions, + type Receiver, + type ReceiverEvent, ReceiverInconsistentStateError, + type ReceiverProcessEventErrorHandlerArgs, + type ReceiverUnhandledRequestHandlerArgs, HTTPModuleFunctions as httpFunc, - HTTPResponseAck, - InstallProviderOptions, - InstallURLOptions, - BufferedIncomingMessage, - ReceiverProcessEventErrorHandlerArgs, - ReceiverUnhandledRequestHandlerArgs, } from '@slack/bolt'; +import { ConsoleLogger, type LogLevel, type Logger } from '@slack/logger'; +import { type CallbackOptions, type InstallPathOptions, InstallProvider } from '@slack/oauth'; +import Koa from 'koa'; export interface InstallerOptions { stateStore?: InstallProviderOptions['stateStore']; // default ClearStateStore @@ -36,6 +34,11 @@ export interface InstallerOptions { authorizationUrl?: InstallProviderOptions['authorizationUrl']; } +type CustomPropertiesExtractor = ( + request: BufferedIncomingMessage, + // biome-ignore lint/suspicious/noExplicitAny: custom app properties can be anything +) => Record; + export interface KoaReceiverOptions { signingSecret: string | (() => PromiseLike); logger?: Logger; @@ -52,13 +55,8 @@ export interface KoaReceiverOptions { installerOptions?: InstallerOptions; koa?: Koa; router?: Router; - customPropertiesExtractor?: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; - processEventErrorHandler?: ( - args: ReceiverProcessEventErrorHandlerArgs - ) => Promise; + customPropertiesExtractor?: CustomPropertiesExtractor; + processEventErrorHandler?: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; // NOTE: As we use setTimeout under the hood, this cannot be async unhandledRequestHandler?: (args: ReceiverUnhandledRequestHandlerArgs) => void; unhandledRequestTimeoutMillis?: number; @@ -79,10 +77,7 @@ export default class KoaReceiver implements Receiver { private unhandledRequestTimeoutMillis: number; - private customPropertiesExtractor: ( - request: BufferedIncomingMessage - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => Record; + private customPropertiesExtractor: CustomPropertiesExtractor; private processEventErrorHandler: (args: ReceiverProcessEventErrorHandlerArgs) => Promise; @@ -103,15 +98,15 @@ export default class KoaReceiver implements Receiver { public constructor(options: KoaReceiverOptions) { this.signatureVerification = options.signatureVerification ?? true; this.signingSecretProvider = options.signingSecret; - this.customPropertiesExtractor = options.customPropertiesExtractor !== undefined ? - options.customPropertiesExtractor : - (_) => ({}); + this.customPropertiesExtractor = + options.customPropertiesExtractor !== undefined ? options.customPropertiesExtractor : (_) => ({}); this.path = options.path ?? '/slack/events'; this.unhandledRequestTimeoutMillis = options.unhandledRequestTimeoutMillis ?? 3001; this.koa = options.koa ?? new Koa(); this.router = options.router ?? new Router(); - this.logger = options.logger ?? + this.logger = + options.logger ?? (() => { const defaultLogger = new ConsoleLogger(); if (options.logLevel) { @@ -124,16 +119,10 @@ export default class KoaReceiver implements Receiver { this.unhandledRequestHandler = options.unhandledRequestHandler ?? httpFunc.defaultUnhandledRequestHandler; this.installerOptions = options.installerOptions; - if ( - this.installerOptions && - this.installerOptions.installPath === undefined - ) { + if (this.installerOptions && this.installerOptions.installPath === undefined) { this.installerOptions.installPath = '/slack/install'; } - if ( - this.installerOptions && - this.installerOptions.redirectUriPath === undefined - ) { + if (this.installerOptions && this.installerOptions.redirectUriPath === undefined) { this.installerOptions.redirectUriPath = '/slack/oauth_redirect'; } if (options.clientId && options.clientSecret) { @@ -159,9 +148,10 @@ export default class KoaReceiver implements Receiver { private async signingSecret(): Promise { if (this._signingSecret === undefined) { - this._signingSecret = typeof this.signingSecretProvider === 'string' ? - this.signingSecretProvider : - await this.signingSecretProvider(); + this._signingSecret = + typeof this.signingSecretProvider === 'string' + ? this.signingSecretProvider + : await this.signingSecretProvider(); } return this._signingSecret; } @@ -175,18 +165,10 @@ export default class KoaReceiver implements Receiver { this.installerOptions.redirectUriPath ) { this.router.get(this.installerOptions.installPath, async (ctx) => { - await this.installer?.handleInstallPath( - ctx.req, - ctx.res, - this.installerOptions?.installPathOptions, - ); + await this.installer?.handleInstallPath(ctx.req, ctx.res, this.installerOptions?.installPathOptions); }); this.router.get(this.installerOptions.redirectUriPath, async (ctx) => { - await this.installer?.handleCallback( - ctx.req, - ctx.res, - this.installerOptions?.callbackOptions, - ); + await this.installer?.handleCallback(ctx.req, ctx.res, this.installerOptions?.callbackOptions); }); } @@ -204,8 +186,7 @@ export default class KoaReceiver implements Receiver { req, ); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; if (this.signatureVerification) { this.logger.warn(`Failed to parse and verify the request data: ${e.message}`); } else { @@ -216,13 +197,12 @@ export default class KoaReceiver implements Receiver { } // Parse request body - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: request bodies can be anything let body: any; try { body = httpFunc.parseHTTPRequestBody(bufferedReq); } catch (err) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const e = err as any; + const e = err as Error; this.logger.warn(`Malformed request body: ${e.message}`); httpFunc.buildNoBodyResponse(res, 400); return; @@ -282,7 +262,7 @@ export default class KoaReceiver implements Receiver { }); } - public start(port: number = 3000): Promise { + public start(port = 3000): Promise { // Enable routes this.koa.use(this.router.routes()).use(this.router.allowedMethods()); diff --git a/examples/custom-receiver/src/fastify-main.ts b/examples/custom-receiver/src/fastify-main.ts index 6449e275b..f4a6ccbfa 100644 --- a/examples/custom-receiver/src/fastify-main.ts +++ b/examples/custom-receiver/src/fastify-main.ts @@ -1,16 +1,13 @@ -/* eslint-disable no-param-reassign */ -/* eslint-disable import/no-internal-modules */ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-disable import/extensions */ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ - -import Fastify from 'fastify'; import { App, FileInstallationStore } from '@slack/bolt'; -import { FileStateStore } from '@slack/oauth'; import { ConsoleLogger, LogLevel } from '@slack/logger'; +import { FileStateStore } from '@slack/oauth'; +import Fastify from 'fastify'; import FastifyReceiver from './FastifyReceiver'; +if (!process.env.SLACK_SIGNING_SECRET) { + throw new Error('SLACK_SIGNING_SECRET environment variable not found!'); +} + const logger = new ConsoleLogger(); logger.setLevel(LogLevel.DEBUG); @@ -21,8 +18,7 @@ fastify.get('/', async (_, res) => { }); const receiver = new FastifyReceiver({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - signingSecret: process.env.SLACK_SIGNING_SECRET!, + signingSecret: process.env.SLACK_SIGNING_SECRET, clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, scopes: ['commands', 'chat:write', 'app_mentions:read'], diff --git a/examples/custom-receiver/src/koa-main.ts b/examples/custom-receiver/src/koa-main.ts index 0f87bc1a4..8855b8703 100644 --- a/examples/custom-receiver/src/koa-main.ts +++ b/examples/custom-receiver/src/koa-main.ts @@ -1,17 +1,14 @@ -/* eslint-disable no-param-reassign */ -/* eslint-disable import/no-internal-modules */ -/* eslint-disable import/no-extraneous-dependencies */ -/* eslint-disable import/extensions */ -/* eslint-disable node/no-extraneous-import */ -/* eslint-disable import/no-extraneous-dependencies */ - import Router from '@koa/router'; -import Koa from 'koa'; import { App, FileInstallationStore } from '@slack/bolt'; -import { FileStateStore } from '@slack/oauth'; import { ConsoleLogger, LogLevel } from '@slack/logger'; +import { FileStateStore } from '@slack/oauth'; +import Koa from 'koa'; import KoaReceiver from './KoaReceiver'; +if (!process.env.SLACK_SIGNING_SECRET) { + throw new Error('SLACK_SIGNING_SECRET environment variable not found!'); +} + const logger = new ConsoleLogger(); logger.setLevel(LogLevel.DEBUG); const koa = new Koa(); @@ -22,8 +19,7 @@ router.get('/', async (ctx) => { }); const receiver = new KoaReceiver({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - signingSecret: process.env.SLACK_SIGNING_SECRET!, + signingSecret: process.env.SLACK_SIGNING_SECRET, clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, scopes: ['commands', 'chat:write', 'app_mentions:read'], diff --git a/examples/custom-receiver/tsconfig.eslint.json b/examples/custom-receiver/tsconfig.eslint.json deleted file mode 100644 index d19325c71..000000000 --- a/examples/custom-receiver/tsconfig.eslint.json +++ /dev/null @@ -1,13 +0,0 @@ -// This config is only used to allow ESLint to use a different include / exclude setting than the actual build -{ - // extend the build config to share compilerOptions - "extends": "./tsconfig.json", - "compilerOptions": { - // Setting "noEmit" prevents misuses of this config such as using it to produce a build - "noEmit": true - }, - "include": [ - // Since extending a config overwrites the entire value for "include", those value are copied here - "src/**/*", - ] -} diff --git a/examples/custom-receiver/tsconfig.json b/examples/custom-receiver/tsconfig.json index 9bb02861c..1e3120461 100644 --- a/examples/custom-receiver/tsconfig.json +++ b/examples/custom-receiver/tsconfig.json @@ -1,18 +1,18 @@ { + "$schema": "https://json.schemastore.org/tsconfig", "compilerOptions": { - "target": "es6", - "module": "commonjs", - "moduleResolution": "node", + "allowJs": true, + "allowSyntheticDefaultImports": true, "esModuleInterop": true, + "module": "CommonJS", + "moduleResolution": "node", "resolveJsonModule": true, - "allowSyntheticDefaultImports": true, - "strict": true, - "allowJs": true, - "sourceMap": true, "rootDir": "src", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "es6", "outDir": "dist" }, - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/examples/deploy-aws-lambda/app.js b/examples/deploy-aws-lambda/app.js index 0e01d0d4f..11a81931a 100644 --- a/examples/deploy-aws-lambda/app.js +++ b/examples/deploy-aws-lambda/app.js @@ -25,29 +25,29 @@ app.message('hello', async ({ message, say }) => { await say({ blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Hey there <@${message.user}>!` + type: 'section', + text: { + type: 'mrkdwn', + text: `Hey there <@${message.user}>!`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Click Me', }, - "action_id": "button_click" - } - } + action_id: 'button_click', + }, + }, ], - text: `Hey there <@${message.user}>!` + text: `Hey there <@${message.user}>!`, }); }); // Listens for an action from a button click app.action('button_click', async ({ body, ack, say }) => { await ack(); - + await say(`<@${body.user.id}> clicked the button`); }); @@ -61,4 +61,4 @@ app.message('goodbye', async ({ message, say }) => { module.exports.handler = async (event, context, callback) => { const handler = await awsLambdaReceiver.start(); return handler(event, context, callback); -} +}; diff --git a/examples/deploy-aws-lambda/package.json b/examples/deploy-aws-lambda/package.json index 15645cdab..a5268d8ad 100644 --- a/examples/deploy-aws-lambda/package.json +++ b/examples/deploy-aws-lambda/package.json @@ -9,7 +9,7 @@ }, "license": "MIT", "dependencies": { - "@slack/bolt": "^3.2.0" + "@slack/bolt": "^3" }, "devDependencies": { "serverless": "^2.13.0", diff --git a/examples/deploy-heroku/app.js b/examples/deploy-heroku/app.js index 16f8e5109..bc38cf450 100644 --- a/examples/deploy-heroku/app.js +++ b/examples/deploy-heroku/app.js @@ -3,7 +3,7 @@ const { App } = require('@slack/bolt'); // Initializes your app with your bot token and signing secret const app = new App({ token: process.env.SLACK_BOT_TOKEN, - signingSecret: process.env.SLACK_SIGNING_SECRET + signingSecret: process.env.SLACK_SIGNING_SECRET, }); // Listens to incoming messages that contain "hello" @@ -12,22 +12,22 @@ app.message('hello', async ({ message, say }) => { await say({ blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Hey there <@${message.user}>!` + type: 'section', + text: { + type: 'mrkdwn', + text: `Hey there <@${message.user}>!`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Click Me" + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Click Me', }, - "action_id": "button_click" - } - } + action_id: 'button_click', + }, + }, ], - text: `Hey there <@${message.user}>!` + text: `Hey there <@${message.user}>!`, }); }); @@ -48,4 +48,4 @@ app.message('goodbye', async ({ message, say }) => { await app.start(process.env.PORT || 3000); console.log('โšก๏ธ Bolt app is running!'); -})(); \ No newline at end of file +})(); diff --git a/examples/deploy-heroku/package.json b/examples/deploy-heroku/package.json index d7fceaad7..043311b27 100644 --- a/examples/deploy-heroku/package.json +++ b/examples/deploy-heroku/package.json @@ -10,6 +10,6 @@ }, "license": "MIT", "dependencies": { - "@slack/bolt": "^3.2.0" + "@slack/bolt": "^3" } } diff --git a/examples/getting-started-typescript/package.json b/examples/getting-started-typescript/package.json index 7fa4e96a2..1b2230a3c 100644 --- a/examples/getting-started-typescript/package.json +++ b/examples/getting-started-typescript/package.json @@ -11,11 +11,12 @@ "license": "MIT", "dependencies": { "@slack/bolt": "^3.3.0", - "dotenv": "^8.2.0" + "dotenv": "^16" }, "devDependencies": { - "@types/node": "^14.14.35", - "ts-node": "^9.1.1", - "typescript": "^4.2.3" + "@tsconfig/node18": "^18.2.4", + "@types/node": "^18", + "ts-node": "^10", + "typescript": "5.3.3" } } diff --git a/examples/getting-started-typescript/src/app.ts b/examples/getting-started-typescript/src/app.ts index e5f944b65..f78d351e8 100644 --- a/examples/getting-started-typescript/src/app.ts +++ b/examples/getting-started-typescript/src/app.ts @@ -1,7 +1,5 @@ -/* eslint-disable no-console */ -/* eslint-disable import/no-internal-modules */ import './utils/env'; -import { App, LogLevel } from '@slack/bolt'; +import { App, type BlockButtonAction, LogLevel } from '@slack/bolt'; const app = new App({ token: process.env.SLACK_BOT_TOKEN, @@ -41,12 +39,11 @@ app.message('hello', async ({ message, say }) => { } }); -app.action('button_click', async ({ body, ack, say }) => { +app.action('button_click', async ({ body, ack, say }) => { // Acknowledge the action await ack(); // we know that this event comes from a button click from a message in a channel, so `say` will be available. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await say!(`<@${body.user.id}> clicked the button`); + await say(`<@${body.user.id}> clicked the button`); }); (async () => { diff --git a/examples/getting-started-typescript/src/basic.ts b/examples/getting-started-typescript/src/basic.ts index 29612433a..9df8819dc 100644 --- a/examples/getting-started-typescript/src/basic.ts +++ b/examples/getting-started-typescript/src/basic.ts @@ -1,8 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unused-vars */ -/* eslint-disable no-console */ -/* eslint-disable import/no-internal-modules */ import './utils/env'; -import { App, LogLevel, subtype, BotMessageEvent, BlockAction } from '@slack/bolt'; +import { App, type BlockAction, LogLevel, subtype, type types } from '@slack/bolt'; const app = new App({ token: process.env.SLACK_BOT_TOKEN, @@ -25,7 +22,7 @@ app.message(':wave:', async ({ message, say }) => { */ // Listens for messages containing "knock knock" and responds with an italicized "who's there?" app.message('knock knock', async ({ say }) => { - await say('_Who\'s there?_'); + await say("_Who's there?_"); }); // Sends a section block with datepicker when someone reacts with a ๐Ÿ“… emoji @@ -35,22 +32,24 @@ app.event('reaction_added', async ({ event, client }) => { await client.chat.postMessage({ text: 'Pick a reminder date', channel: event.item.channel, - blocks: [{ - type: 'section', - text: { - type: 'mrkdwn', - text: 'Pick a date for me to remind you', - }, - accessory: { - type: 'datepicker', - action_id: 'datepicker_remind', - initial_date: '2019-04-28', - placeholder: { - type: 'plain_text', - text: 'Select a date', + blocks: [ + { + type: 'section', + text: { + type: 'mrkdwn', + text: 'Pick a date for me to remind you', + }, + accessory: { + type: 'datepicker', + action_id: 'datepicker_remind', + initial_date: '2019-04-28', + placeholder: { + type: 'plain_text', + text: 'Select a date', + }, }, }, - }], + ], }); } }); @@ -75,7 +74,8 @@ app.event('team_join', async ({ event, client, logger }) => { }); app.message(subtype('bot_message'), async ({ message, logger }) => { - const botMessage = (message as BotMessageEvent); + // TODO: the need to cast here is due to https://github.com/slackapi/bolt-js/issues/796 + const botMessage = message as types.BotMessageEvent; logger.info(`The bot user ${botMessage.user} said ${botMessage.text}`); }); @@ -110,32 +110,30 @@ app.action('approve_button', async ({ ack }) => { // Your listener function will only be called when the action_id matches 'select_user' // AND the block_id matches 'assign_ticket' -app.action({ action_id: 'select_user', block_id: 'assign_ticket' }, +app.action( + { action_id: 'select_user', block_id: 'assign_ticket' }, async ({ body, client, ack, logger }) => { await ack(); try { // Make sure the event is not in a view - if (body.message) { + if (body.message && body.channel) { await client.reactions.add({ name: 'white_check_mark', - timestamp: body.message?.ts, - channel: body.channel?.id, + timestamp: body.message.ts, + channel: body.channel.id, // if the body has a message, we know it has a channel, too. }); } } catch (error) { logger.error(error); } - }); + }, +); // Your middleware will be called every time an interactive component with the action_id โ€œapprove_buttonโ€ is triggered -app.action('approve_button', async ({ ack, say }) => { +app.action('approve_button', async ({ ack, say }) => { // Acknowledge action request await ack(); - // `say` is possibly undefined because an action could come from a surface where we cannot post message, e.g. a view. - // we will use a non-null assertion (!) to tell TypeScript to ignore the fact it may be undefined, - // but take care about the originating surface for these events when using these utilities! - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await say!('Request approved ๐Ÿ‘'); + await say('Request approved ๐Ÿ‘'); }); (async () => { diff --git a/examples/getting-started-typescript/src/utils/env.ts b/examples/getting-started-typescript/src/utils/env.ts index ed0f69d75..56a77a528 100644 --- a/examples/getting-started-typescript/src/utils/env.ts +++ b/examples/getting-started-typescript/src/utils/env.ts @@ -1,5 +1,5 @@ // for details see https://github.com/motdotla/dotenv/blob/master/examples/typescript/ -import { resolve } from 'path'; +import { resolve } from 'node:path'; import { config } from 'dotenv'; const pathToConfig = '../../.env'; diff --git a/examples/getting-started-typescript/tsconfig.eslint.json b/examples/getting-started-typescript/tsconfig.eslint.json deleted file mode 100644 index d19325c71..000000000 --- a/examples/getting-started-typescript/tsconfig.eslint.json +++ /dev/null @@ -1,13 +0,0 @@ -// This config is only used to allow ESLint to use a different include / exclude setting than the actual build -{ - // extend the build config to share compilerOptions - "extends": "./tsconfig.json", - "compilerOptions": { - // Setting "noEmit" prevents misuses of this config such as using it to produce a build - "noEmit": true - }, - "include": [ - // Since extending a config overwrites the entire value for "include", those value are copied here - "src/**/*", - ] -} diff --git a/examples/getting-started-typescript/tsconfig.json b/examples/getting-started-typescript/tsconfig.json index 9bb02861c..e6a379f01 100644 --- a/examples/getting-started-typescript/tsconfig.json +++ b/examples/getting-started-typescript/tsconfig.json @@ -1,18 +1,12 @@ { + "extends": "@tsconfig/node18/tsconfig.json", "compilerOptions": { - "target": "es6", - "module": "commonjs", - "moduleResolution": "node", - "esModuleInterop": true, "resolveJsonModule": true, "allowSyntheticDefaultImports": true, - "strict": true, "allowJs": true, "sourceMap": true, "rootDir": "src", "outDir": "dist" }, - "include": [ - "src/**/*" - ] + "include": ["src/**/*"] } diff --git a/examples/hubot-example/README.md b/examples/hubot-example/README.md deleted file mode 100644 index 3a7cf933a..000000000 --- a/examples/hubot-example/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Hubot example - -The [Hubot](https://hubot.github.com/) bot framework introduces you to its capabilities using a helpful -[example script](https://github.com/hubotio/generator-hubot/blob/master/generators/app/templates/scripts/example.js). -Bolt has many of the same capabilities. Whether you're migrating your Hubot to Bolt, or are looking for examples on -how Bolt might handle some common tasks, this example helps you understand Bolt a little better. - -## Set up - -Before running this example app, you'll need to [create a Slack app](https://api.slack.com/apps?new_app=1), configure -it, and install it into a development workspace. - -1. **Add a Bot user**. Choose any display name and user name you like. -2. **Enable the Events API**. Input a Request URL, wait for it to be verified, and save it. This step may require -[getting a public URL that can be used for development](https://slack.dev/node-slack-sdk/tutorials/local-development). -The app will be listening on the path `/slack/events`. -3. **Subscribe to Bot Events**: `app_mention`, `member_joined_channel`, `member_left_channel`, `message.channels`, -`message.groups`, `message.im`, `message.mpim`. -4. **Install the app to the development workspace**. - -Once these steps are complete, you should have a Bot User access token and the Signing Secret. These values will be -used below. - -## Run the app - -1. Clone this repository: `git clone https://github.com/slackapi/bolt.git` - -2. Install dependencies and build: `cd bolt; npm install` - -3. Start the app, substituting your own values into the environment variables: - -```shell -$ SLACK_SIGNING_SECRET= SLACK_BOT_TOKEN= HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING=42 node examples/hubot-example/script.js -``` - -## Try it out - -Read through the various examples in `script.js` in this directory. Try messaging the bot user to test how each listener -behaves. diff --git a/examples/hubot-example/script.js b/examples/hubot-example/script.js deleted file mode 100644 index 86c2e51e0..000000000 --- a/examples/hubot-example/script.js +++ /dev/null @@ -1,297 +0,0 @@ -// Exercise: port the Hubot example script to the Bolt API. -// -// The commented sections are from the Hubot example script, while the uncommented sections below them are the -// same functionality using Bolt. - -// module.exports = (robot) => { - -// ===================================== -// === Variable Declarations === -// ===================================== - -// Create a constant with Greetings you can add more to this if you like by putting more in if you wish -// Just follow the syntax below -const enterReplies = ['Hi', 'Target Acquired', 'Firing', 'Hello friend.', 'Gotcha', 'I see you'] -// Create a constant with Leave Replies you can add more to this if you like by putting more in if you wish -// Just follow the syntax below -const leaveReplies = ['Are you still there?', 'Target lost', 'Searching'] - -// LOL responses again you can add more following this convention below -const lulz = ['lol', 'rofl', 'lmao']; - -// Grab a random LOL value -const randomLulz = () => lulz[Math.floor(Math.random() * lulz.length)]; -// Grab a random greeting from above -const randomEnterReply = () => enterReplies[Math.floor(Math.random() * enterReplies.length)]; -// Grab a random Leave Reply -const randomLeaveReply = () => leaveReplies[Math.floor(Math.random() * leaveReplies.length)]; - -// This is pulled from your .env file so if you can change the answer in this file - -const answer = process.env.HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING - -// Let annoyIntervalId be null to start - -let annoyIntervalId = null - -//Pull in the .env file for use in this file -require('dotenv').config() - -// Create a constant with App and Direct mention methods - -const { App, directMention } = require('../../dist'); -const app = new App({ - signingSecret: process.env.SLACK_SIGNING_SECRET, - token: process.env.SLACK_BOT_TOKEN, -}); - -(async () => { - //Start up the app - const server = await app.start(process.env.PORT || 3000); - console.log('โšก๏ธ Bolt app is running!', server.address()); -})(); - - // robot.hear(/badger/i, (res) => { - // res.send('Badgers? BADGERS? WE DONโ€™T NEED NO STINKIN BADGERS') - // }) - -// If someone says badgers the bot responds with Badgers? BADGERS? WE DONโ€™T NEED NO STINKIN BADGERS -app.message('badger', async ({ say }) => { await say('Badgers? BADGERS? WE DONโ€™T NEED NO STINKIN BADGERS'); }); - - // robot.respond(/open the (.*) doors/i, (res) => { - // const doorType = res.match[1] - // - // if (doorType === 'pod bay') { - // res.reply('Iโ€™m afraid I canโ€™t let you do that.') - // return - // } - // - // res.reply('Opening #{doorType} doors') - // }) - -// I never go this one to work maybe you need to say open the pod bay? - -app.message(/open the (.*) doors/i, async ({ say, context }) => { - const doorType = context.matches[1]; - - const text = (doorType === 'pod bay') ? - 'Iโ€™m afraid I canโ€™t let you do that.' : - `Opening ${doorType} doors`; - - await say(text); -}); - - // robot.hear(/I like pie/i, (res) => { - // res.emote('makes a freshly baked pie') - // }) - -// If you say I like pie the bot responds with pie emoji -app.message('I like pie', async ({ message, context }) => { - try { - await app.client.reactions.add({ - token: context.botToken, - name: 'pie', - channel: message.channel, - timestamp: message.ts, - }); - } catch (error) { - console.error(error); - } -}); - - - - // robot.respond(`/${lulz.join('|')}/i`, (res) => { - // res.send(res.random(lulz)) - // }) - - - -// If someone says lol it will respond with a random response. You could change this to directMention -// This would make the bot only respond if you @botname lol perhaps this is annoying - -app.event('app_mention', ({ say }) => say(randomLulz())); -// OR -// app.message(directMention(), ({ say }) => say(randomLulz())); - - // robot.topic((res) => { - // res.send(`${res.message.text}? Thatโ€™s a Paddlin`) - // }) - - // ๐Ÿšซ there's no Events API event type for channel topic changed. - - - // robot.enter((res) => { - // res.send(res.random(enterReplies)) - // }) - // robot.leave((res) => { - // res.send(res.random(leaveReplies)) - // }) - - -// If a new user enters the chat respond with a random greeting -app.event('member_joined_channel', async ({ say }) => { await say(randomEnterReply()); }); - -// If a user leaves respond with a random Leave reply -app.event('member_left_channel', async ({ say }) => { await say(randomLeaveReply()); }); - - - // robot.respond(/what is the answer to the ultimate question of life/, (res) => { - // if (answer) { - // res.send(`${answer}, but what is the question?`) - // return - // } - // - // res.send('Missing HUBOT_ANSWER_TO_THE_ULTIMATE_QUESTION_OF_LIFE_THE_UNIVERSE_AND_EVERYTHING in environment: please set and try again') - // }) - -// If you ask what is the answer to the ultimate question of life it will respond with what is in your .env file -app.message( - directMention(), - 'what is the answer to the ultimate question of life', - async ({ say }) => { - if (answer) { await say(`${answer}, but what is the question?`); } - }); - - // robot.respond(/you are a little slow/, (res) => { - // setTimeout(() => res.send('Who you calling "slow"?'), 60 * 1000) - // }) - -// If you are a little slow it will respond in 60 * 1000 seconds with Who you calling "slow"? -app.message('you are a little slow', async ({ say, context }) => { - setTimeout(async function() { await say(`Who you calling "_slow_"`) }, 60 * 1000); -}); - -//end listening for someone to say the bot is slow - // robot.respond(/annoy me/, (res) => { - // if (annoyIntervalId) { - // res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH') - // return - // } - // - // res.send('Hey, want to hear the most annoying sound in the world?') - // annoyIntervalId = setInterval(() => res.send('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'), 1000) - // }) - // - // robot.respond(/unannoy me/, (res) => { - // if (!annoyIntervalId) { - // res.send('Not annoying you right now, am I?') - // return - // } - // - // res.send('OKAY, OKAY, OKAY!') - // clearInterval(annoyIntervalId) - // annoyIntervalId = null - // }) - -// This example is quite annoying to say the least if you @botname annoy me -// It will annoy you with AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH -// Until you tell it to stop with @botname unannoy me - -app.message(directMention(), /(? { - if (annoyIntervalId) { - await say('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'); - return; - } - - await say('Hey, want to hear the most annoying sound in the world?'); - annoyIntervalId = setInterval(() => { - say('AAAAAAAAAAAEEEEEEEEEEEEEEEEEEEEEEEEIIIIIIIIHHHHHHHHHH'); - }, 1000); - }); - -app.message(directMention(), 'unannoy me', async ({ say }) => { - if (!annoyIntervalId) { - await say('Not annoying you right now, am I?'); - return; - } - await say('OKAY, OKAY, OKAY!'); - clearInterval(annoyIntervalId); - annoyIntervalId = null; - }); - - // robot.router.post('/hubot/chatsecrets/:room', (req, res) => { - // const room = req.params.room - // const data = JSON.parse(req.body.payload) - // const secret = data.secret - // - // robot.messageRoom(room, `I have a secret: ${secret}`) - // - // res.send('OK') - // }) - - // ๐Ÿšซ stand up your own express router - - // robot.error((error, response) => { - // const message = `DOES NOT COMPUTE: ${error.toString()}` - // robot.logger.error(message) - // - // if (response) { - // response.reply(message) - // } - // }) - -// Possibly not needed the built in error handler will output errors to the console anyway -// Could use a try{} catch{} around something where you wish to halt the program if an error occurs - -app.error(async (error) => { - // Check the details of the error to handle cases where you should retry sending a message or stop the app - const message = `DOES NOT COMPUTE: ${error.toString()}`; - console.error(message); -}); - - // ๐Ÿšซ no reply handling from global error handler -}); - - // robot.respond(/have a soda/i, (response) => { - // // Get number of sodas had (coerced to a number). - // const sodasHad = +robot.brain.get('totalSodas') || 0 - // - // if (sodasHad > 4) { - // response.reply('Iโ€™m too fizzyโ€ฆ') - // return - // } - // - // response.reply('Sure!') - // robot.brain.set('totalSodas', sodasHad + 1) - // }) - // - // robot.respond(/sleep it off/i, (res) => { - // robot.brain.set('totalSodas', 0) - // res.reply('zzzzz') - // }) - -// NOTE: In a real application, you should provide a convoStore option to the App constructor. The default convoStore -// only persists data to memory, so its lost when the process terminates. -// This example really does not work without a conversation store for me it just keeps saying Sure! -// It should after 4 requests to have a soda it should say I'm to fizzy.. - -app.message(directMention(), 'have a soda', async ({ context, say }) => { - // Initialize conversation - const conversation = context.conversation !== undefined ? context.conversation : {}; - - // Initialize data for this listener - conversation.sodasHad = conversation.sodasHad !== undefined ? conversation.sodasHad : 0; - - if (conversation.sodasHad > 4) { - await say('I\'m too fizzy...'); - return; - } - - await say('Sure!'); - conversation.sodasHad += 1; - try { - await context.updateConversation(conversation); - } catch (error) { - console.error(error); - } -}); -// if you say @botnam sleep it off. It responds with zzzz -app.message(directMention(), 'sleep it off', async ({ context, say }) => { - try { - await context.updateConversation({ ...context.conversation, sodasHad: 0 }); - await say('zzzzz'); - } catch (error) { - console.error(error); - } -}); diff --git a/examples/message-metadata/app-manifest.json b/examples/message-metadata/app-manifest.json index 911ec2f07..454f5b930 100644 --- a/examples/message-metadata/app-manifest.json +++ b/examples/message-metadata/app-manifest.json @@ -1,51 +1,41 @@ { "display_information": { - "name": "Message Metadata Example" + "name": "Message Metadata Example" }, "features": { - "bot_user": { - "display_name": "Message Metadata Bot", - "always_online": false - }, - "slash_commands": [ - { - "command": "/post", - "description": "Post Message Metadata", - "should_escape": false - } - ] + "bot_user": { + "display_name": "Message Metadata Bot", + "always_online": false + }, + "slash_commands": [ + { + "command": "/post", + "description": "Post Message Metadata", + "should_escape": false + } + ] }, "oauth_config": { - "redirect_urls": [ - "https://localhost" - ], - "scopes": { - "bot": [ - "metadata.message:read", - "chat:write", - "commands" - ] - } + "redirect_urls": ["https://localhost"], + "scopes": { + "bot": ["metadata.message:read", "chat:write", "commands"] + } }, "settings": { - "event_subscriptions": { - "bot_events": [ - "message_metadata_deleted", - "message_metadata_posted", - "message_metadata_updated" - ], - "metadata_subscriptions": [ - { - "app_id": "[app id]", - "event_type": "my_event" - } - ] - }, - "interactivity": { - "is_enabled": true - }, - "org_deploy_enabled": false, - "socket_mode_enabled": true, - "token_rotation_enabled": false + "event_subscriptions": { + "bot_events": ["message_metadata_deleted", "message_metadata_posted", "message_metadata_updated"], + "metadata_subscriptions": [ + { + "app_id": "[app id]", + "event_type": "my_event" + } + ] + }, + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false } } diff --git a/examples/message-metadata/app.js b/examples/message-metadata/app.js index 5143ac8af..fad0bd4bb 100644 --- a/examples/message-metadata/app.js +++ b/examples/message-metadata/app.js @@ -4,7 +4,7 @@ const app = new App({ token: process.env.SLACK_BOT_TOKEN, appToken: process.env.SLACK_APP_TOKEN, socketMode: true, - logLevel: LogLevel.DEBUG + logLevel: LogLevel.DEBUG, }); (async () => { @@ -12,44 +12,43 @@ const app = new App({ console.log('โšก๏ธ Bolt app started'); })(); - // Listen to slash command // Post a message with Message Metadata -app.command('/post', async ({ ack, command, say }) => { +app.command('/post', async ({ ack, say }) => { await ack(); await say({ - text: "Message Metadata Posting", + text: 'Message Metadata Posting', metadata: { - "event_type": "my_event", - "event_payload": { - "key": "value" - } - } + event_type: 'my_event', + event_payload: { + key: 'value', + }, + }, }); }); app.event('message_metadata_posted', async ({ event, say }) => { const { message_ts: thread_ts } = event; await say({ - text: "Message Metadata Posted", + text: 'Message Metadata Posted', blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Message Metadata Posted" - } + type: 'section', + text: { + type: 'mrkdwn', + text: 'Message Metadata Posted', + }, }, { - "type": "context", - "elements": [ + type: 'context', + elements: [ { - "type": "mrkdwn", - "text": `${JSON.stringify(event.metadata)}` - } - ] - } + type: 'mrkdwn', + text: `${JSON.stringify(event.metadata)}`, + }, + ], + }, ], - thread_ts - }) + thread_ts, + }); }); diff --git a/examples/oauth-express-receiver/app.js b/examples/oauth-express-receiver/app.js index 07241c8c1..779812ba9 100644 --- a/examples/oauth-express-receiver/app.js +++ b/examples/oauth-express-receiver/app.js @@ -1,7 +1,7 @@ const { App, ExpressReceiver, LogLevel, FileInstallationStore } = require('@slack/bolt'); // Create an ExpressReceiver -const receiver = new ExpressReceiver({ +const receiver = new ExpressReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET, clientId: process.env.SLACK_CLIENT_ID, clientSecret: process.env.SLACK_CLIENT_SECRET, diff --git a/examples/oauth/app.js b/examples/oauth/app.js index 289d09695..d45f96d12 100644 --- a/examples/oauth/app.js +++ b/examples/oauth/app.js @@ -63,7 +63,7 @@ const app = new App({ // without rendering the web page with "Add to Slack" button. // This flag is available in @slack/bolt v3.7 or higher // directInstall: true, - } + }, }); (async () => { diff --git a/examples/socket-mode-oauth/package.json b/examples/socket-mode-oauth/package.json index 3cc238b7b..3b8ae34fa 100644 --- a/examples/socket-mode-oauth/package.json +++ b/examples/socket-mode-oauth/package.json @@ -12,4 +12,4 @@ "dependencies": { "@slack/bolt": "^3.10.0" } -} \ No newline at end of file +} diff --git a/examples/socket-mode/app.js b/examples/socket-mode/app.js index 6b1a01e72..174764880 100644 --- a/examples/socket-mode/app.js +++ b/examples/socket-mode/app.js @@ -42,17 +42,17 @@ app.event('app_home_opened', async ({ event, client }) => { await client.views.publish({ user_id: event.user, view: { - "type": "home", - "blocks": [ + type: 'home', + blocks: [ { - "type": "section", - "block_id": "section678", - "text": { - "type": "mrkdwn", - "text": "App Home Published" + type: 'section', + block_id: 'section678', + text: { + type: 'mrkdwn', + text: 'App Home Published', }, - } - ] + }, + ], }, }); }); @@ -75,65 +75,64 @@ app.shortcut('launch_shortcut', async ({ shortcut, body, ack, context, client }) const result = await client.views.open({ trigger_id: shortcut.trigger_id, view: { - type: "modal", + type: 'modal', title: { - type: "plain_text", - text: "My App" + type: 'plain_text', + text: 'My App', }, close: { - type: "plain_text", - text: "Close" + type: 'plain_text', + text: 'Close', }, blocks: [ { - type: "section", + type: 'section', text: { - type: "mrkdwn", - text: "About the simplest modal you could conceive of :smile:\n\nMaybe or ." - } + type: 'mrkdwn', + text: 'About the simplest modal you could conceive of :smile:\n\nMaybe or .', + }, }, { - type: "context", + type: 'context', elements: [ { - type: "mrkdwn", - text: "Psssst this modal was designed using " - } - ] - } - ] - } + type: 'mrkdwn', + text: 'Psssst this modal was designed using ', + }, + ], + }, + ], + }, }); } catch (error) { console.error(error); } }); - // subscribe to 'app_mention' event in your App config // need app_mentions:read and chat:write scopes app.event('app_mention', async ({ event, context, client, say }) => { try { await say({ - "blocks": [ + blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Thanks for the mention <@${event.user}>! Click my fancy button` + type: 'section', + text: { + type: 'mrkdwn', + text: `Thanks for the mention <@${event.user}>! Click my fancy button`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Button", - "emoji": true + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Button', + emoji: true, }, - "value": "click_me_123", - "action_id": "first_button" - } - } - ] + value: 'click_me_123', + action_id: 'first_button', + }, + }, + ], }); } catch (error) { console.error(error); @@ -146,25 +145,25 @@ app.message('hello', async ({ message, say }) => { // say() sends a message to the channel where the event was triggered // no need to directly use 'chat.postMessage', no need to include token await say({ - "blocks": [ + blocks: [ { - "type": "section", - "text": { - "type": "mrkdwn", - "text": `Thanks for the mention <@${message.user}>! Click my fancy button` + type: 'section', + text: { + type: 'mrkdwn', + text: `Thanks for the mention <@${message.user}>! Click my fancy button`, }, - "accessory": { - "type": "button", - "text": { - "type": "plain_text", - "text": "Button", - "emoji": true + accessory: { + type: 'button', + text: { + type: 'plain_text', + text: 'Button', + emoji: true, }, - "value": "click_me_123", - "action_id": "first_button" - } - } - ] + value: 'click_me_123', + action_id: 'first_button', + }, + }, + ], }); }); diff --git a/package.json b/package.json index 6348658cf..d4cf7f828 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slack/bolt", - "version": "3.22.0", + "version": "4.0.0-rc.5", "description": "A framework for building Slack apps, fast.", "author": "Slack Technologies, LLC", "license": "MIT", @@ -21,18 +21,18 @@ "dist/**/*" ], "engines": { - "node": ">=14.21.3", - "npm": ">=6.14.18" + "node": ">=18", + "npm": ">=8.6.0" }, "scripts": { "prepare": "npm run build", "build": "npm run build:clean && tsc", - "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", - "lint": "eslint --fix --ext .ts src", - "mocha": "TS_NODE_PROJECT=tsconfig.json nyc mocha --config .mocharc.json \"src/**/*.spec.ts\"", - "test": "npm run build && npm run lint && npm run mocha && npm run test:types", - "test:coverage": "npm run mocha && nyc report --reporter=text", - "test:types": "tsd", + "build:clean": "shx rm -rf ./dist ./coverage", + "lint": "npx @biomejs/biome check --write docs src test examples", + "test": "npm run build && npm run lint && npm run test:types && npm run test:coverage", + "test:unit": "TS_NODE_PROJECT=tsconfig.json mocha --config test/unit/.mocharc.json", + "test:coverage": "c8 npm run test:unit", + "test:types": "tsd --files test/types", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, "repository": "slackapi/bolt", @@ -42,48 +42,34 @@ }, "dependencies": { "@slack/logger": "^4.0.0", - "@slack/oauth": "^2.6.3", - "@slack/socket-mode": "^1.3.6", + "@slack/oauth": "^3", + "@slack/socket-mode": "^2.0.2", "@slack/types": "^2.13.0", - "@slack/web-api": "^6.13.0", - "@types/express": "^4.16.1", - "@types/promise.allsettled": "^1.0.3", - "@types/tsscmp": "^1.0.0", + "@slack/web-api": "^7", + "@types/express": "^4.17.21", "axios": "^1.7.4", - "express": "^4.21.0", + "express": "^5.0.0", "path-to-regexp": "^8.1.0", - "promise.allsettled": "^1.0.2", - "raw-body": "^2.3.3", + "raw-body": "^3", "tsscmp": "^1.0.6" }, "devDependencies": { + "@biomejs/biome": "^1.9.0", + "@tsconfig/node18": "^18.2.4", "@types/chai": "^4.1.7", "@types/mocha": "^10.0.1", "@types/node": "22.7.5", "@types/sinon": "^7.0.11", - "@typescript-eslint/eslint-plugin": "^4.4.1", - "@typescript-eslint/parser": "^4.4.0", + "@types/tsscmp": "^1.0.0", + "c8": "^10.1.2", "chai": "~4.3.0", - "eslint": "^7.26.0", - "eslint-config-airbnb-base": "^14.2.1", - "eslint-config-airbnb-typescript": "^12.3.1", - "eslint-plugin-import": "^2.28.0", - "eslint-plugin-jsdoc": "^30.6.1", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-node": "^11.1.0", - "eslint-plugin-react": "^7.29.3", - "eslint-plugin-react-hooks": "^4.3.0", "mocha": "^10.2.0", - "nyc": "^15.1.0", "rewiremock": "^3.13.4", "shx": "^0.3.2", "sinon": "^18.0.1", "source-map-support": "^0.5.12", "ts-node": "^10.9.2", - "tsd": "^0.22.0", - "typescript": "4.8.4" - }, - "tsd": { - "directory": "types-tests" + "tsd": "^0.31.2", + "typescript": "5.3.3" } } diff --git a/src/App-basic-features.spec.ts b/src/App-basic-features.spec.ts deleted file mode 100644 index 78a815380..000000000 --- a/src/App-basic-features.spec.ts +++ /dev/null @@ -1,1327 +0,0 @@ -import 'mocha'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { LogLevel } from '@slack/logger'; -import { WebClientOptions, WebClient } from '@slack/web-api'; -import { Override, mergeOverrides, createFakeLogger } from './test-helpers'; -import { ErrorCode } from './errors'; -import { - Receiver, - ReceiverEvent, - SayFn, - NextFn, -} from './types'; -import { ConversationStore } from './conversation-store'; -import App from './App'; -import SocketModeReceiver from './receivers/SocketModeReceiver'; - -// Utility functions -const noop = () => Promise.resolve(undefined); -const noopMiddleware = async ({ next }: { next: NextFn }) => { - await next(); -}; -const noopAuthorize = () => Promise.resolve({}); - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -// Dummies (values that have no real behavior but pass through the system opaquely) -function createDummyReceiverEvent(type: string = 'dummy_event_type'): ReceiverEvent { - // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a - // IncomingEventType.Event - return { - body: { - event: { - type, - }, - }, - ack: noop, - }; -} - -describe('App basic features', () => { - describe('constructor', () => { - describe('with a custom port value in HTTP Mode', () => { - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - it('should accept a port value at the top-level', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ token: '', signingSecret: '', port: 9999 }); - // Assert - assert.equal((app as any).receiver.port, 9999); - }); - it('should accept a port value under installerOptions', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ token: '', signingSecret: '', port: 7777, installerOptions: { port: 9999 } }); - // Assert - assert.equal((app as any).receiver.port, 9999); - }); - }); - - describe('with a custom port value in Socket Mode', () => { - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const installationStore = { - storeInstallation: async () => { }, - fetchInstallation: async () => { throw new Error('Failed fetching installation'); }, - deleteInstallation: async () => { }, - }; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - it('should accept a port value at the top-level', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ - socketMode: true, - appToken: '', - port: 9999, - clientId: '', - clientSecret: '', - stateSecret: '', - installerOptions: { - }, - installationStore, - }); - // Assert - assert.equal((app as any).receiver.httpServerPort, 9999); - }); - it('should accept a port value under installerOptions', async () => { - // Arrange - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ - socketMode: true, - appToken: '', - port: 7777, - clientId: '', - clientSecret: '', - stateSecret: '', - installerOptions: { - port: 9999, - }, - installationStore, - }); - // Assert - assert.equal((app as any).receiver.httpServerPort, 9999); - }); - }); - - // TODO: test when the single team authorization results fail. that should still succeed but warn. it also means - // that the `ignoreSelf` middleware will fail (or maybe just warn) a bunch. - describe('with successful single team authorization results', () => { - it('should succeed with a token for single team authorization', async () => { - // Arrange - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ token: '', signingSecret: '' }); - - // Assert - // TODO: verify that the fake bot ID and fake bot user ID are retrieved - assert.instanceOf(app, MockApp); - }); - it('should pass the given token to app.client', async () => { - // Arrange - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ token: 'xoxb-foo-bar', signingSecret: '' }); - - // Assert - assert.isDefined(app.client); - assert.equal(app.client.token, 'xoxb-foo-bar'); - }); - }); - it('should succeed with an authorize callback', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - const app = new MockApp({ authorize: authorizeCallback, signingSecret: '' }); - - // Assert - assert(authorizeCallback.notCalled, 'Should not call the authorize callback on instantiation'); - assert.instanceOf(app, MockApp); - }); - it('should fail without a token for single team authorization, authorize callback, nor oauth installer', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should fail when both a token and authorize callback are specified', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', authorize: authorizeCallback, signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - assert(authorizeCallback.notCalled); - } - }); - it('should fail when both a token is specified and OAuthInstaller is initialized', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - assert(authorizeCallback.notCalled); - } - }); - it('should fail when both a authorize callback is specified and OAuthInstaller is initialized', async () => { - // Arrange - const authorizeCallback = sinon.fake(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ authorize: authorizeCallback, clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - assert(authorizeCallback.notCalled); - } - }); - describe('with a custom receiver', () => { - it('should succeed with no signing secret', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - const app = new MockApp({ receiver: new FakeReceiver(), authorize: noopAuthorize }); - - // Assert - assert.instanceOf(app, MockApp); - }); - }); - it('should fail when no signing secret for the default receiver is specified', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ authorize: noopAuthorize }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should fail when both socketMode and a custom receiver are specified', async () => { - // Arrange - const fakeReceiver = new FakeReceiver(); - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: fakeReceiver }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should succeed when both socketMode and SocketModeReceiver are specified', async () => { - // Arrange - const fakeBotId = 'B_FAKE_BOT_ID'; - const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), - ); - const MockApp = await importApp(overrides); - const socketModeReceiver = new SocketModeReceiver({ appToken: '' }); - - // Act - const app = new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: socketModeReceiver }); - - // Assert - assert.instanceOf(app, MockApp); - }); - it('should initialize MemoryStore conversation store by default', async () => { - // Arrange - const fakeMemoryStore = sinon.fake(); - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withMemoryStore(fakeMemoryStore), - withConversationContext(fakeConversationContext), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ authorize: noopAuthorize, signingSecret: '' }); - - // Assert - assert.instanceOf(app, MockApp); - assert(fakeMemoryStore.calledWithNew); - assert(fakeConversationContext.called); - }); - it('should initialize without a conversation store when option is false', async () => { - // Arrange - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withConversationContext(fakeConversationContext), - ); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ convoStore: false, authorize: noopAuthorize, signingSecret: '' }); - - // Assert - assert.instanceOf(app, MockApp); - assert(fakeConversationContext.notCalled); - }); - describe('with a custom conversation store', () => { - it('should initialize the conversation store', async () => { - // Arrange - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withConversationContext(fakeConversationContext), - ); - const dummyConvoStore = Symbol() as unknown as ConversationStore; - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ convoStore: dummyConvoStore, authorize: noopAuthorize, signingSecret: '' }); - - // Assert - assert.instanceOf(app, MockApp); - assert(fakeConversationContext.firstCall.calledWith(dummyConvoStore)); - }); - }); - describe('with custom redirectUri supplied', () => { - it('should fail when missing installerOptions', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', signingSecret: '', redirectUri: 'http://example.com/redirect' }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - it('should fail when missing installerOptions.redirectUriPath', async () => { - // Arrange - const MockApp = await importApp(); - - // Act - try { - new MockApp({ token: '', signingSecret: '', redirectUri: 'http://example.com/redirect', installerOptions: {} }); // eslint-disable-line no-new - assert.fail(); - } catch (error: any) { - // Assert - assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); - } - }); - }); - it('with clientOptions', async () => { - const fakeConstructor = sinon.fake(); - const overrides = mergeOverrides(withNoopAppMetadata(), { - '@slack/web-api': { - WebClient: class { - public constructor() { - fakeConstructor(...arguments); // eslint-disable-line prefer-rest-params - } - }, - }, - }); - - const MockApp = await importApp(overrides); - - const clientOptions = { slackApiUrl: 'proxy.slack.com' }; - - new MockApp({ clientOptions, authorize: noopAuthorize, signingSecret: '', logLevel: LogLevel.ERROR }); // eslint-disable-line no-new - - assert.ok(fakeConstructor.called); - - const [token, options] = fakeConstructor.lastCall.args; - assert.strictEqual(undefined, token, 'token should be undefined'); - assert.strictEqual(clientOptions.slackApiUrl, options.slackApiUrl); - assert.strictEqual(LogLevel.ERROR, options.logLevel, 'override logLevel'); - }); - it('should not perform auth.test API call if tokenVerificationEnabled is false', async () => { - // Arrange - const fakeConstructor = sinon.fake(); - const overrides = mergeOverrides(withNoopAppMetadata(), { - '@slack/web-api': { - WebClient: class { - public constructor() { - fakeConstructor(...arguments); // eslint-disable-line prefer-rest-params - } - - public auth = { - test: () => { - throw new Error('This API method call should not be performed'); - }, - }; - }, - }, - }); - - const MockApp = await importApp(overrides); - const app = new MockApp({ - token: 'xoxb-completely-invalid-token', - signingSecret: 'invalid-one', - tokenVerificationEnabled: false, - }); - // Assert - assert.instanceOf(app, MockApp); - }); - - it('should fail in await App#init()', async () => { - // Arrange - const fakeConstructor = sinon.fake(); - const overrides = mergeOverrides(withNoopAppMetadata(), { - '@slack/web-api': { - WebClient: class { - public constructor() { - fakeConstructor(...arguments); // eslint-disable-line prefer-rest-params - } - - public auth = { - test: () => { - throw new Error('Failing for init() test!'); - }, - }; - }, - }, - }); - - const MockApp = await importApp(overrides); - const app = new MockApp({ - token: 'xoxb-completely-invalid-token', - signingSecret: 'invalid-one', - deferInitialization: true, - }); - // Assert - assert.instanceOf(app, MockApp); - try { - // call #start() before #init() - await app.start(); - assert.fail('The start() method should fail before init() call'); - } catch (err: any) { - assert.equal(err.message, 'This App instance is not yet initialized. Call `await App#init()` before starting the app.'); - } - try { - await app.init(); - assert.fail('The init() method should fail here'); - } catch (err: any) { - assert.equal(err.message, 'Failing for init() test!'); - } - }); - - describe('with developerMode', () => { - it('should accept developerMode: true', async () => { - // Arrange - const overrides = mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient('B_FAKE_BOT_ID', 'U_FAKE_BOT_USER_ID'), - ); - const fakeLogger = createFakeLogger(); - const MockApp = await importApp(overrides); - // Act - const app = new MockApp({ logger: fakeLogger, token: '', appToken: '', developerMode: true }); - // Assert - assert.equal((app as any).logLevel, LogLevel.DEBUG); - assert.equal((app as any).socketMode, true); - }); - }); - - // TODO: tests for ignoreSelf option - // TODO: tests for logger and logLevel option - // TODO: tests for providing botId and botUserId options - // TODO: tests for providing endpoints option - }); - - describe('#start', () => { - // The following test case depends on a definition of App that is generic on its Receiver type. This will be - // addressed in the future. It cannot even be left uncommented with the `it.skip()` global because it will fail - // TypeScript compilation as written. - // it('should pass calls through to receiver', async () => { - // // Arrange - // const dummyReturn = Symbol(); - // const dummyParams = [Symbol(), Symbol()]; - // const fakeReceiver = new FakeReceiver(); - // const MockApp = await importApp(); - // const app = new MockApp({ receiver: fakeReceiver, authorize: noopAuthorize }); - // fakeReceiver.start = sinon.fake.returns(dummyReturn); - // // Act - // const actualReturn = await app.start(...dummyParams); - // // Assert - // assert.deepEqual(actualReturn, dummyReturn); - // assert.deepEqual(dummyParams, fakeReceiver.start.firstCall.args); - // }); - // TODO: another test case to take the place of the one above (for coverage until the definition of App is made - // generic). - }); - - describe('#stop', () => { - it('should pass calls through to receiver', async () => { - // Arrange - const dummyReturn = Symbol(); - const dummyParams = [Symbol(), Symbol()]; - const fakeReceiver = new FakeReceiver(); - const MockApp = await importApp(); - fakeReceiver.stop = sinon.fake.returns(dummyReturn); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: noopAuthorize }); - const actualReturn = await app.stop(...dummyParams); - - // Assert - assert.deepEqual(actualReturn, dummyReturn); - assert.deepEqual(dummyParams, fakeReceiver.stop.firstCall.args); - }); - }); - - let fakeReceiver: FakeReceiver; - let fakeErrorHandler: SinonSpy; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - fakeErrorHandler = sinon.fake(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - describe('middleware and listener arguments', () => { - const dummyChannelId = 'CHANNEL_ID'; - let overrides: Override; - const baseEvent = createDummyReceiverEvent(); - - function buildOverrides(secondOverrides: Override[]): Override { - overrides = mergeOverrides( - withNoopAppMetadata(), - ...secondOverrides, - withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)), - ); - return overrides; - } - - describe('respond()', () => { - it('should respond to events with a response_url', async () => { - // Arrange - const responseText = 'response'; - const responseUrl = 'https://fake.slack/response_url'; - const actionId = 'block_action_id'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.action(actionId, async ({ respond }) => { - await respond(responseText); - }); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent({ - // IncomingEventType.Action (app.action) - body: { - type: 'block_actions', - response_url: responseUrl, - actions: [ - { - action_id: actionId, - }, - ], - channel: {}, - user: {}, - team: {}, - }, - ack: noop, - }); - - // Assert - assert(fakeErrorHandler.notCalled); - assert.equal(fakeAxiosPost.callCount, 1); - // Assert that each call to fakeAxiosPost had the right arguments - assert(fakeAxiosPost.calledWith(responseUrl, { text: responseText })); - }); - - it('should respond with a response object', async () => { - // Arrange - const responseObject = { text: 'response' }; - const responseUrl = 'https://fake.slack/response_url'; - const actionId = 'block_action_id'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.action(actionId, async ({ respond }) => { - await respond(responseObject); - }); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent({ - // IncomingEventType.Action (app.action) - body: { - type: 'block_actions', - response_url: responseUrl, - actions: [ - { - action_id: actionId, - }, - ], - channel: {}, - user: {}, - team: {}, - }, - ack: noop, - }); - - // Assert - assert.equal(fakeAxiosPost.callCount, 1); - // Assert that each call to fakeAxiosPost had the right arguments - assert(fakeAxiosPost.calledWith(responseUrl, responseObject)); - }); - it('should be able to use respond for view_submission payloads', async () => { - // Arrange - const responseObject = { text: 'response' }; - const responseUrl = 'https://fake.slack/response_url'; - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.view('view-id', async ({ respond }) => { - await respond(responseObject); - }); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent({ - ack: noop, - body: { - type: 'view_submission', - team: {}, - user: {}, - view: { - id: 'V111', - type: 'modal', - callback_id: 'view-id', - state: {}, - title: {}, - close: {}, - submit: {}, - }, - response_urls: [ - { - block_id: 'b', - action_id: 'a', - channel_id: 'C111', - response_url: 'https://fake.slack/response_url', - }, - ], - }, - }); - - // Assert - assert.equal(fakeAxiosPost.callCount, 1); - // Assert that each call to fakeAxiosPost had the right arguments - assert(fakeAxiosPost.calledWith(responseUrl, responseObject)); - }); - }); - - describe('logger', () => { - it('should be available in middleware/listener args', async () => { - // Arrange - const MockApp = await importApp(overrides); - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.use(async ({ logger, body, next }) => { - logger.info(body); - await next(); - }); - - app.event('app_home_opened', async ({ logger, event }) => { - logger.debug(event); - }); - - const receiverEvents = [ - { - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'app_home_opened', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }, - ]; - - // Act - await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.isTrue(fakeLogger.info.called); - assert.isTrue(fakeLogger.debug.called); - }); - - it('should work in the case both logger and logLevel are given', async () => { - // Arrange - const MockApp = await importApp(overrides); - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - logLevel: LogLevel.DEBUG, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.use(async ({ logger, body, next }) => { - logger.info(body); - await next(); - }); - - app.event('app_home_opened', async ({ logger, event }) => { - logger.debug(event); - }); - - const receiverEvents = [ - { - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'app_home_opened', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }, - ]; - - // Act - await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.isTrue(fakeLogger.info.called); - assert.isTrue(fakeLogger.debug.called); - assert.isTrue(fakeLogger.setLevel.called); - }); - }); - - describe('client', () => { - it('should be available in middleware/listener args', async () => { - // Arrange - const MockApp = await importApp( - mergeOverrides( - withNoopAppMetadata(), - withSuccessfulBotUserFetchingWebClient('B123', 'U123'), - ), - ); - const tokens = ['xoxb-123', 'xoxp-456', 'xoxb-123']; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: () => { - const token = tokens.pop(); - if (typeof token === 'undefined') { - return Promise.resolve({ botId: 'B123' }); - } - if (token.startsWith('xoxb-')) { - return Promise.resolve({ botToken: token, botId: 'B123' }); - } - return Promise.resolve({ userToken: token, botId: 'B123' }); - }, - }); - app.use(async ({ client, next }) => { - await client.auth.test(); - await next(); - }); - const clients: WebClient[] = []; - app.event('app_home_opened', async ({ client }) => { - clients.push(client); - await client.auth.test(); - }); - - const event = { - body: { - type: 'event_callback', - token: 'legacy', - team_id: 'T123', - api_app_id: 'A123', - event: { - type: 'app_home_opened', - event_ts: '123.123', - user: 'U123', - text: 'Hi there!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }; - const receiverEvents = [event, event, event]; - - // Act - await Promise.all(receiverEvents.map((evt) => fakeReceiver.sendEvent(evt))); - - // Assert - assert.isUndefined(app.client.token); - - assert.equal(clients[0].token, 'xoxb-123'); - assert.equal(clients[1].token, 'xoxp-456'); - assert.equal(clients[2].token, 'xoxb-123'); - - assert.notEqual(clients[0], clients[1]); - assert.strictEqual(clients[0], clients[2]); - }); - - it("should be to the global app client when authorization doesn't produce a token", async () => { - // Arrange - const MockApp = await importApp(); - const app = new MockApp({ - receiver: fakeReceiver, - authorize: noopAuthorize, - ignoreSelf: false, - }); - const globalClient = app.client; - - // Act - let clientArg: WebClient | undefined; - app.use(async ({ client }) => { - clientArg = client; - }); - await fakeReceiver.sendEvent(createDummyReceiverEvent()); - - // Assert - assert.equal(globalClient, clientArg); - }); - }); - - describe('say()', () => { - function createChannelContextualReceiverEvents(channelId: string): ReceiverEvent[] { - return [ - // IncomingEventType.Event with channel in payload - { - ...baseEvent, - body: { - event: { - channel: channelId, - }, - team_id: 'TEAM_ID', - }, - }, - // IncomingEventType.Event with channel in item - { - ...baseEvent, - body: { - event: { - item: { - channel: channelId, - }, - }, - team_id: 'TEAM_ID', - }, - }, - // IncomingEventType.Command - { - ...baseEvent, - body: { - command: '/COMMAND_NAME', - channel_id: channelId, - team_id: 'TEAM_ID', - }, - }, - // IncomingEventType.Action from block action, interactive message, or message action - { - ...baseEvent, - body: { - actions: [{}], - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - // IncomingEventType.Action from dialog submission - { - ...baseEvent, - body: { - type: 'dialog_submission', - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - ]; - } - - it('should send a simple message to a channel where the incoming event originates', async () => { - // Arrange - const fakePostMessage = sinon.fake.resolves({}); - overrides = buildOverrides([withPostMessage(fakePostMessage)]); - const MockApp = await importApp(overrides); - - const dummyMessage = 'test'; - const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - // By definition, these events should all produce a say function, so we cast args.say into a SayFn - const say = (args as any).say as SayFn; - await say(dummyMessage); - }); - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); - // Assert that each call to fakePostMessage had the right arguments - fakePostMessage.getCalls().forEach((call) => { - const firstArg = call.args[0]; - assert.propertyVal(firstArg, 'text', dummyMessage); - assert.propertyVal(firstArg, 'channel', dummyChannelId); - }); - assert(fakeErrorHandler.notCalled); - }); - - it('should send a complex message to a channel where the incoming event originates', async () => { - // Arrange - const fakePostMessage = sinon.fake.resolves({}); - overrides = buildOverrides([withPostMessage(fakePostMessage)]); - const MockApp = await importApp(overrides); - - const dummyMessage = { text: 'test' }; - const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - // By definition, these events should all produce a say function, so we cast args.say into a SayFn - const say = (args as any).say as SayFn; - await say(dummyMessage); - }); - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); - // Assert that each call to fakePostMessage had the right arguments - fakePostMessage.getCalls().forEach((call) => { - const firstArg = call.args[0]; - assert.propertyVal(firstArg, 'channel', dummyChannelId); - Object.keys(dummyMessage).forEach((prop) => { - assert.propertyVal(firstArg, prop, (dummyMessage as any)[prop]); - }); - }); - assert(fakeErrorHandler.notCalled); - }); - - function createReceiverEventsWithoutSay(channelId: string): ReceiverEvent[] { - return [ - // IncomingEventType.Options from block action - { - ...baseEvent, - body: { - type: 'block_suggestion', - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - // IncomingEventType.Options from interactive message or dialog - { - ...baseEvent, - body: { - name: 'select_field_name', - channel: { - id: channelId, - }, - user: { - id: 'USER_ID', - }, - team: { - id: 'TEAM_ID', - }, - }, - }, - // IncomingEventType.Event without a channel context - { - ...baseEvent, - body: { - event: {}, - team_id: 'TEAM_ID', - }, - }, - ]; - } - - it("should not exist in the arguments on incoming events that don't support say", async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - - const assertionAggregator = sinon.fake(); - const dummyReceiverEvents = createReceiverEventsWithoutSay(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - assert.isUndefined((args as any).say); - // If the above assertion fails, then it would throw an AssertionError and the following line will not be - // called - assertionAggregator(); - }); - - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(assertionAggregator.callCount, dummyReceiverEvents.length); - }); - - it("should handle failures through the App's global error handler", async () => { - // Arrange - const fakePostMessage = sinon.fake.rejects(new Error('fake error')); - overrides = buildOverrides([withPostMessage(fakePostMessage)]); - const MockApp = await importApp(overrides); - - const dummyMessage = { text: 'test' }; - const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.use(async (args) => { - // By definition, these events should all produce a say function, so we cast args.say into a SayFn - const say = (args as any).say as SayFn; - await say(dummyMessage); - }); - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(fakeErrorHandler.callCount, dummyReceiverEvents.length); - }); - }); - - describe('ack()', () => { - it('should be available in middleware/listener args', async () => { - // Arrange - const MockApp = await importApp(overrides); - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.use(async ({ ack, next }) => { - if (ack) { - // this should be called even if app.view listeners do not exist - await ack(); - return; - } - fakeLogger.info('Events API'); - await next(); - }); - - app.event('app_home_opened', async ({ logger, event }) => { - logger.debug(event); - }); - - let ackInMiddlewareCalled = false; - - const receiverEvents = [ - { - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'app_home_opened', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - tab: 'home', - view: {}, - }, - }, - respond: noop, - ack: noop, - }, - { - body: { - type: 'view_submission', - team: {}, - user: {}, - view: { - id: 'V111', - type: 'modal', - callback_id: 'view-id', - state: {}, - title: {}, - close: {}, - submit: {}, - }, - }, - respond: noop, - ack: async () => { - ackInMiddlewareCalled = true; - }, - }, - ]; - - // Act - await Promise.all( - receiverEvents.map((event) => fakeReceiver.sendEvent(event)), - ); - - // Assert - assert.isTrue(fakeLogger.info.called); - assert.isTrue(ackInMiddlewareCalled); - }); - }); - - describe('context', () => { - it('should be able to use the app_installed_team_id when provided by the payload', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([ - withNoopWebClient(), - withAxiosPost(fakeAxiosPost), - ]); - const MockApp = await importApp(overrides); - - // Act - const app = new MockApp({ - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - - app.view('view-id', async ({ ack, context, view }) => { - assert.equal('T-installed-workspace', context.teamId); - assert.notEqual('T-installed-workspace', view.team_id); - await ack(); - }); - app.error(fakeErrorHandler); - - let ackCalled = false; - - const receiverEvent = { - ack: async () => { - ackCalled = true; - }, - body: { - type: 'view_submission', - team: {}, - user: {}, - view: { - id: 'V111', - type: 'modal', - callback_id: 'view-id', - state: {}, - title: {}, - close: {}, - submit: {}, - app_installed_team_id: 'T-installed-workspace', - }, - }, - }; - - // Act - await fakeReceiver.sendEvent(receiverEvent); - - // Assert - assert.isTrue(ackCalled); - }); - }); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -function withSuccessfulBotUserFetchingWebClient(botId: string, botUserId: string): Override { - return { - '@slack/web-api': { - WebClient: class { - public token?: string; - - public constructor(token?: string, _options?: WebClientOptions) { - this.token = token; - } - - public auth = { - test: sinon.fake.resolves({ user_id: botUserId }), - }; - - public users = { - info: sinon.fake.resolves({ - user: { - profile: { - bot_id: botId, - }, - }, - }), - }; - }, - }, - }; -} - -function withPostMessage(spy: SinonSpy): Override { - return { - '@slack/web-api': { - WebClient: class { - public chat = { - postMessage: spy, - }; - }, - }, - }; -} - -function withAxiosPost(spy: SinonSpy): Override { - return { - axios: { - create: () => ({ - post: spy, - }), - }, - }; -} - -function withMemoryStore(spy: SinonSpy): Override { - return { - './conversation-store': { - MemoryStore: spy, - }, - }; -} - -function withConversationContext(spy: SinonSpy): Override { - return { - './conversation-store': { - conversationContext: spy, - }, - }; -} diff --git a/src/App-built-in-middleware.spec.ts b/src/App-built-in-middleware.spec.ts deleted file mode 100644 index 1d118a0f4..000000000 --- a/src/App-built-in-middleware.spec.ts +++ /dev/null @@ -1,609 +0,0 @@ -import 'mocha'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Override, mergeOverrides, createFakeLogger, delay } from './test-helpers'; -import { ErrorCode, UnknownError, AuthorizationError, CodedError, isCodedError } from './errors'; -import { - Receiver, - ReceiverEvent, - NextFn, -} from './types'; -import App, { ExtendedErrorHandlerArgs } from './App'; - -// Utility functions -const noop = () => Promise.resolve(undefined); -const noopMiddleware = async ({ next }: { next: NextFn }) => { - await next(); -}; -const noopAuthorize = () => Promise.resolve({}); - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -// Dummies (values that have no real behavior but pass through the system opaquely) -function createDummyReceiverEvent(type: string = 'dummy_event_type'): ReceiverEvent { - // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a - // IncomingEventType.Event - return { - body: { - event: { - type, - }, - }, - ack: noop, - }; -} - -describe('App built-in middleware and mechanism', () => { - let fakeReceiver: FakeReceiver; - let fakeErrorHandler: SinonSpy; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - fakeErrorHandler = sinon.fake(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - // TODO: verify that authorize callback is called with the correct properties and responds correctly to - // various return values - - function createInvalidReceiverEvents(): ReceiverEvent[] { - // TODO: create many more invalid receiver events (fuzzing) - return [ - { - body: {}, - ack: sinon.fake.resolves(undefined), - }, - ]; - } - - it('should warn and skip when processing a receiver event with unknown type (never crash)', async () => { - // Arrange - const fakeLogger = createFakeLogger(); - const fakeMiddleware = sinon.fake(noopMiddleware); - const invalidReceiverEvents = createInvalidReceiverEvents(); - const MockApp = await importApp(); - - // Act - const app = new MockApp({ receiver: fakeReceiver, logger: fakeLogger, authorize: noopAuthorize }); - app.use(fakeMiddleware); - await Promise.all(invalidReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert(fakeErrorHandler.notCalled); - assert(fakeMiddleware.notCalled); - assert.isAtLeast(fakeLogger.warn.callCount, invalidReceiverEvents.length); - }); - - it('should warn, send to global error handler, and skip when a receiver event fails authorization', async () => { - // Arrange - const fakeLogger = createFakeLogger(); - const fakeMiddleware = sinon.fake(noopMiddleware); - const dummyOrigError = new Error('auth failed'); - const dummyAuthorizationError = new AuthorizationError('auth failed', dummyOrigError); - const dummyReceiverEvent = createDummyReceiverEvent(); - const MockApp = await importApp(); - - // Act - const app = new MockApp({ - receiver: fakeReceiver, - logger: fakeLogger, - authorize: sinon.fake.rejects(dummyAuthorizationError), - }); - app.use(fakeMiddleware); - app.error(fakeErrorHandler); - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert(fakeMiddleware.notCalled); - assert(fakeLogger.warn.called); - assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); - assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'code', ErrorCode.AuthorizationError); - assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'original', dummyAuthorizationError.original); - }); - - describe('global middleware', () => { - let fakeFirstMiddleware: SinonSpy; - let fakeSecondMiddleware: SinonSpy; - let app: App; - let dummyReceiverEvent: ReceiverEvent; - - beforeEach(async () => { - const fakeConversationContext = sinon.fake.returns(noopMiddleware); - const overrides = mergeOverrides( - withNoopAppMetadata(), - withNoopWebClient(), - withMemoryStore(sinon.fake()), - withConversationContext(fakeConversationContext), - ); - const MockApp = await importApp(overrides); - - dummyReceiverEvent = createDummyReceiverEvent(); - fakeFirstMiddleware = sinon.fake(noopMiddleware); - fakeSecondMiddleware = sinon.fake(noopMiddleware); - - app = new MockApp({ - logger: createFakeLogger(), - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - }); - - it('should error if next called multiple times', async () => { - // Arrange - app.use(fakeFirstMiddleware); - app.use(async ({ next }) => { - await next(); - await next(); - }); - app.use(fakeSecondMiddleware); - app.error(fakeErrorHandler); - - // Act - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); - }); - - it('correctly waits for async listeners', async () => { - let changed = false; - - app.use(async ({ next }) => { - await delay(10); - changed = true; - - await next(); - }); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - assert.isTrue(changed); - assert(fakeErrorHandler.notCalled); - }); - - it('throws errors which can be caught by upstream async listeners', async () => { - const thrownError = new Error('Error handling the message :('); - let caughtError; - - app.use(async ({ next }) => { - try { - await next(); - } catch (err: any) { - caughtError = err; - } - }); - - app.use(async () => { - throw thrownError; - }); - - app.error(fakeErrorHandler); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - - assert.equal(caughtError, thrownError); - assert(fakeErrorHandler.notCalled); - }); - - it('calls async middleware in declared order', async () => { - const message = ':wave:'; - let middlewareCount = 0; - - /** - * Middleware that, when called, asserts that it was called in the correct order - * @param orderDown The order it should be called when processing middleware down the chain - * @param orderUp The order it should be called when processing middleware up the chain - */ - const assertOrderMiddleware = (orderDown: number, orderUp: number) => async ({ next }: { next?: NextFn }) => { - await delay(10); - middlewareCount += 1; - assert.equal(middlewareCount, orderDown); - if (next !== undefined) { - await next(); - } - middlewareCount += 1; - assert.equal(middlewareCount, orderUp); - }; - - app.use(assertOrderMiddleware(1, 8)); - app.message(message, assertOrderMiddleware(3, 6), assertOrderMiddleware(4, 5)); - app.use(assertOrderMiddleware(2, 7)); - app.error(fakeErrorHandler); - - await fakeReceiver.sendEvent({ - ...dummyReceiverEvent, - body: { - type: 'event_callback', - event: { - type: 'message', - text: message, - }, - }, - }); - - assert.equal(middlewareCount, 8); - assert(fakeErrorHandler.notCalled); - }); - - it('should, on error, call the global error handler, not extended', async () => { - const error = new Error('Everything is broke, you probably should restart, if not then good luck'); - - app.use(() => { - throw error; - }); - - app.error(async (codedError: CodedError) => { - assert.instanceOf(codedError, UnknownError); - assert.equal(codedError.message, error.message); - }); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - }); - - it('should, on error, call the global error handler, extended', async () => { - const error = new Error('Everything is broke, you probably should restart, if not then good luck'); - // Need to change value of private property for testing purposes - // Accessing through bracket notation because it is private - // eslint-disable-next-line @typescript-eslint/dot-notation - app['extendedErrorHandler'] = true; - - app.use(() => { - throw error; - }); - - app.error(async (args: ExtendedErrorHandlerArgs) => { - assert.property(args, 'error'); - assert.property(args, 'body'); - assert.property(args, 'context'); - assert.property(args, 'logger'); - assert.isDefined(args.error); - assert.isDefined(args.body); - assert.isDefined(args.context); - assert.isDefined(args.logger); - assert.equal(args.error.message, error.message); - }); - - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Need to change value of private property for testing purposes - // Accessing through bracket notation because it is private - // eslint-disable-next-line @typescript-eslint/dot-notation - app['extendedErrorHandler'] = false; - }); - - it('with a default global error handler, rejects App#ProcessEvent', async () => { - const error = new Error('The worst has happened, bot is beyond saving, always hug servers'); - let actualError; - - app.use(() => { - throw error; - }); - - try { - await fakeReceiver.sendEvent(dummyReceiverEvent); - } catch (err: any) { - actualError = err; - } - - assert.instanceOf(actualError, UnknownError); - assert.equal(actualError.message, error.message); - }); - }); - - describe('listener middleware', () => { - let app: App; - const eventType = 'some_event_type'; - const dummyReceiverEvent = createDummyReceiverEvent(eventType); - - beforeEach(async () => { - const MockAppNoOverrides = await importApp(); - app = new MockAppNoOverrides({ - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - app.error(fakeErrorHandler); - }); - - it('should bubble up errors in listeners to the global error handler', async () => { - // Arrange - const errorToThrow = new Error('listener error'); - - // Act - app.event(eventType, async () => { - throw errorToThrow; - }); - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert(fakeErrorHandler.calledOnce); - const error = fakeErrorHandler.firstCall.args[0]; - assert.equal(error.code, ErrorCode.UnknownError); - assert.equal(error.original, errorToThrow); - }); - - it('should aggregate multiple errors in listeners for the same incoming event', async () => { - // Arrange - const errorsToThrow = [new Error('first listener error'), new Error('second listener error')]; - function createThrowingListener(toBeThrown: Error): () => Promise { - return async () => { - throw toBeThrown; - }; - } - - // Act - app.event(eventType, createThrowingListener(errorsToThrow[0])); - app.event(eventType, createThrowingListener(errorsToThrow[1])); - await fakeReceiver.sendEvent(dummyReceiverEvent); - - // Assert - assert(fakeErrorHandler.calledOnce); - const error = fakeErrorHandler.firstCall.args[0]; - assert.ok(isCodedError(error)); - assert(error.code === ErrorCode.MultipleListenerError); - assert.isArray(error.originals); - if (error.originals) assert.sameMembers(error.originals, errorsToThrow); - }); - - it('should detect invalid event names', async () => { - app.event('app_mention', async () => {}); - app.event('message', async () => {}); - assert.throws(() => app.event('message.channels', async () => {}), 'Although the document mentions'); - assert.throws(() => app.event(/message\..+/, async () => {}), 'Although the document mentions'); - }); - - // https://github.com/slackapi/bolt-js/issues/1457 - it('should not cause a runtime exception if the last listener middleware invokes next()', async () => new Promise((resolve, reject) => { - app.event('app_mention', async ({ next }) => { - try { - await next(); - resolve(); - } catch (e) { - reject(e); - } - }); - fakeReceiver.sendEvent(createDummyReceiverEvent('app_mention')); - })); - }); - - describe('middleware and listener arguments', () => { - let overrides: Override; - - function buildOverrides(secondOverrides: Override[]): Override { - overrides = mergeOverrides( - withNoopAppMetadata(), - ...secondOverrides, - withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)), - ); - return overrides; - } - - describe('authorize', () => { - it('should extract valid enterprise_id in a shared channel #935', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - let workedAsExpected = false; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: async ({ enterpriseId }) => { - if (enterpriseId !== undefined) { - throw new Error('the enterprise_id must be undefined in this scenario'); - } - return dummyAuthorizationResult; - }, - }); - app.event('message', async () => { - workedAsExpected = true; - }); - await fakeReceiver.sendEvent({ - ack: noop, - body: { - team_id: 'T_connected_grid_workspace', - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'message', - text: ':wave: Hi, this is my first message in a Slack Connect channel!', - user: 'U111', - ts: '1622099033.001500', - team: 'T_this_non_grid_workspace', - channel: 'C111', - channel_type: 'channel', - }, - type: 'event_callback', - authorizations: [ - { - enterprise_id: null, - team_id: 'T_this_non_grid_workspace', - user_id: 'U_authed_user', - is_bot: true, - is_enterprise_install: false, - }, - ], - is_ext_shared_channel: true, - event_context: '2-message-T_connected_grid_workspace-A111-C111', - }, - }); - - // Assert - assert.isTrue(workedAsExpected); - }); - it('should be skipped for tokens_revoked events #674', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - let workedAsExpected = false; - let authorizeCallCount = 0; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: async () => { - authorizeCallCount += 1; - return {}; - }, - }); - app.event('tokens_revoked', async () => { - workedAsExpected = true; - }); - - // The authorize must be called for other events - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'app_mention', - }, - type: 'event_callback', - }, - }); - assert.equal(authorizeCallCount, 1); - - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'tokens_revoked', - tokens: { - oauth: ['P'], - bot: ['B'], - }, - }, - type: 'event_callback', - }, - }); - - // Assert - assert.equal(authorizeCallCount, 1); // still 1 - assert.isTrue(workedAsExpected); - }); - it('should be skipped for app_uninstalled events #674', async () => { - // Arrange - const fakeAxiosPost = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); - const MockApp = await importApp(overrides); - - // Act - let workedAsExpected = false; - let authorizeCallCount = 0; - const app = new MockApp({ - receiver: fakeReceiver, - authorize: async () => { - authorizeCallCount += 1; - return {}; - }, - }); - app.event('app_uninstalled', async () => { - workedAsExpected = true; - }); - - // The authorize must be called for other events - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'app_mention', - }, - type: 'event_callback', - }, - }); - assert.equal(authorizeCallCount, 1); - - await fakeReceiver.sendEvent({ - ack: noop, - body: { - enterprise_id: 'E_org_id', - api_app_id: 'A111', - event: { - type: 'app_uninstalled', - }, - type: 'event_callback', - }, - }); - - // Assert - assert.equal(authorizeCallCount, 1); // still 1 - assert.isTrue(workedAsExpected); - }); - }); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -function withMemoryStore(spy: SinonSpy): Override { - return { - './conversation-store': { - MemoryStore: spy, - }, - }; -} - -function withConversationContext(spy: SinonSpy): Override { - return { - './conversation-store': { - conversationContext: spy, - }, - }; -} - -function withAxiosPost(spy: SinonSpy): Override { - return { - axios: { - create: () => ({ - post: spy, - }), - }, - }; -} diff --git a/src/App-context-types.spec.ts b/src/App-context-types.spec.ts deleted file mode 100644 index 1d75956d6..000000000 --- a/src/App-context-types.spec.ts +++ /dev/null @@ -1,887 +0,0 @@ -import sinon from 'sinon'; -import rewiremock from 'rewiremock'; -import { mergeOverrides, Override } from './test-helpers'; -import { OptionsSource, Receiver, ReceiverEvent, SlackAction, SlackShortcut, SlackViewAction } from './types'; -import App, { ActionConstraints, ShortcutConstraints } from './App'; - -// 0 should not be able to extend (1 & ), if it does, SomeType must be Any -// https://stackoverflow.com/a/55541672 -type IfAnyThenElse = 0 extends (1 & TypeToCheck) ? Then : Else; -interface valid { valid: boolean } -interface GlobalContext { globalContextKey: number } -interface MiddlewareContext { middlewareContextKey: number } - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -const noopAuthorize = () => Promise.resolve({}); -const receiver = new FakeReceiver(); - -describe('context typing', () => { - it('use should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Use - Global Context - app.use(async ({ context }) => { - const check = {} as IfAnyThenElse; - check.valid = true; - }); - - // Use - Global & Middleware Context - app.use(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('use should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Use - Middleware Context - app.use(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('message should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Message passes global context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message passes global and middleware context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes global context when using RegExp pattern and passes context to all middleware - app.message(/^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message passes global context when using string pattern and passes context to all middleware - app.message('string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message passes global and middleware context when using RegExp patterns and passes context to all middleware - app.message(/^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes global and middleware context when using String patterns and passes context to all middleware - app.message('string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter with RegExp pattern is aware of global context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, /^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message filter with String pattern is aware of global context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, 'string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message filter with RegExp pattern is aware of global and middleware context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /^regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter with String pattern is aware of global and middleware context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, 'string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter is aware of global context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Message filter is aware of global and middleware context and passes context to all middleware - app.message(async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message with mixed patterns and middleware is aware of global context passes context to all middleware - app.message('test_string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, 'test_string_2', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, /regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - /** - * Message with mixed patterns and middleware is aware of global and - * middleware context and passes context to all middleware - */ - app.message('test_string', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, 'test_string_2', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /regex/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('message should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Message passes middleware context to all middleware - app.message(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes middleware context when using RegExp patterns and passes context to all middleware - app.message(/^regex/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message passes middleware context when using String patterns and passes context to all middleware - app.message('string', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter with RegExp pattern is aware of middleware context and passes context to all middleware - app.message(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /^regex/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Message filter is aware of middleware context and passes context to all middleware - app.message(async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - /** - * Message with mixed patterns and middleware is aware of global and - * middleware context and passes context to all middleware - */ - app.message('test_string', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, 'test_string_2', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, /regex/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('shortcut should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Shortcut with RegExp callbackId is aware of global context and passes context to all middleware - app.shortcut(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Shortcut with string callbackId is aware of global context and passes context to all middleware - app.shortcut('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Shortcut with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.shortcut(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with string callbackId is aware of global and middleware context and passes context to all middleware - app.shortcut('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with constraints is aware of global context and passes context to all middleware - app.shortcut({ type: 'shortcut' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Shortcut with constraints is aware of global and middleware context and passes context to all middleware - app.shortcut, MiddlewareContext>({ type: 'shortcut' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('shortcut should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Shortcut with RegExp callbackId is aware of middleware context and passes context to all middleware - app.shortcut(/callback_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with string callbackId is aware of middleware context and passes context to all middleware - app.shortcut('callback_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Shortcut with constraints is aware of middleware context and passes context to all middleware - app.shortcut({ type: 'shortcut' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('action should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Action with RegExp callbackId is aware of global context and passes context to all middleware - app.action(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Action with string callbackId is aware of global context and passes context to all middleware - app.action('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Action with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.action(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with string callbackId is aware of global and middleware context and passes context to all middleware - app.action('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with constraints is aware of global context and passes context to all middleware - app.action({ type: 'interactive_message' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Action with constraints is aware of global and middleware context and passes context to all middleware - app.action({ type: 'interactive_message' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('action should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Action with RegExp callbackId is aware of middleware context and passes context to all middleware - app.action(/callback_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with string callbackId is aware of middleware context and passes context to all middleware - app.action('callback_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Action with constraints is aware of middleware context and passes context to all middleware - app.action({ type: 'interactive_message' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('command should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - // Command with commandName is aware of global context and passes context to all middleware - - // Command with RegExp commandName is aware of global and middleware context and passes context to all middleware - app.command(/command_name/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Command with String commandName is aware of global and middleware context and passes context to all middleware - app.command('command_name', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Command with RegExp commandName is aware of global and middleware context and passes context to all middleware - app.command(/command_name/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Command with string commandName is aware of global and middleware context and passes context to all middleware - app.command('command_name', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('command should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Command with RegExp commandName is aware of middleware context and passes context to all middleware - app.command(/command_name/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Command with string commandName is aware of middleware context and passes context to all middleware - app.command('command_name', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('options should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Options with RegExp actionId is aware of global context and passes context to all middleware - app.options(/action_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Options with string actionId is aware of global context and passes context to all middleware - app.options('action_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Options with RegExp actionId is aware of global and middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>(/action_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with string actionId is aware of global and middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>('action_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with constraint is aware of global context and passes context to all middleware - app.options({ type: 'block_suggestion' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // Options with constraint is aware of global and middleware context and passes context to all middleware - app.options({ type: 'block_suggestion' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('options should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // Options with RegExp actionId is aware of middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>(/action_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with string actionId is aware of middleware context and passes context to all middleware - app.options<'block_suggestion', MiddlewareContext>('action_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // Options with constraint is aware of middleware context and passes context to all middleware - app.options({ type: 'block_suggestion' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('view should handle global and middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // View with RegExp callbackId is aware of global context and passes context to all middleware - app.view(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // View with string callbackId is aware of global context and passes context to all middleware - app.view('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // View with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.view(/callback_id/, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with string callbackId is aware of global and middleware context and passes context to all middleware - app.view('callback_id', async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with constraint is aware of global context and passes context to all middleware - app.view({ type: 'view_closed' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - }); - - // View with constraint is aware of global and middleware context and passes context to all middleware - app.view({ type: 'view_closed' }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const globalCheck = {} as IfAnyThenElse; - globalCheck.valid = true; - - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); - - it('view should handle middleware context', async () => { - const MockApp = await importApp(); - const app = new MockApp({ receiver, authorize: noopAuthorize }); - - // View with RegExp callbackId is aware of global and middleware context and passes context to all middleware - app.view(/callback_id/, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with string callbackId is aware of global and middleware context and passes context to all middleware - app.view('callback_id', async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - - // View with constraint is aware of global and middleware context and passes context to all middleware - app.view({ type: 'view_closed' }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }, async ({ context }) => { - const middlewareCheck = {} as IfAnyThenElse; - middlewareCheck.valid = true; - }); - }); -}); - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} diff --git a/src/App-routes.spec.ts b/src/App-routes.spec.ts deleted file mode 100644 index e1e22f04b..000000000 --- a/src/App-routes.spec.ts +++ /dev/null @@ -1,1090 +0,0 @@ -import 'mocha'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Override, mergeOverrides, createFakeLogger } from './test-helpers'; -import { - Receiver, - ReceiverEvent, - NextFn, -} from './types'; -import App, { ViewConstraints } from './App'; - -// Utility functions -const noop = () => Promise.resolve(undefined); -const noopMiddleware = async ({ next }: { next: NextFn }) => { - await next(); -}; - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -// Dummies (values that have no real behavior but pass through the system opaquely) -function createDummyReceiverEvent(type: string = 'dummy_event_type'): ReceiverEvent { - // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a - // IncomingEventType.Event - return { - body: { - event: { - type, - }, - }, - ack: noop, - }; -} - -describe('App event routing', () => { - let fakeReceiver: FakeReceiver; - let fakeErrorHandler: SinonSpy; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - fakeErrorHandler = sinon.fake(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - let overrides: Override; - const baseEvent = createDummyReceiverEvent(); - - function buildOverrides(secondOverrides: Override[]): Override { - overrides = mergeOverrides( - withNoopAppMetadata(), - ...secondOverrides, - withMemoryStore(sinon.fake()), - withConversationContext(sinon.fake.returns(noopMiddleware)), - ); - return overrides; - } - - describe('basic pattern coverage', () => { - function createReceiverEvents(): ReceiverEvent[] { - return [ - { - // IncomingEventType.Event (app.event) - ...baseEvent, - body: { - event: {}, - }, - }, - { - // IncomingEventType.Command (app.command) - ...baseEvent, - body: { - command: '/COMMAND_NAME', - is_enterprise_install: 'false', - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'block_actions', - actions: [ - { - action_id: 'block_action_id', - }, - ], - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'message_action_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'another_message_action_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'another_shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'interactive_message', - callback_id: 'interactive_message_callback_id', - actions: [{}], - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Action with dialog submission (app.action) - ...baseEvent, - body: { - type: 'dialog_submission', - callback_id: 'dialog_submission_callback_id', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.Action for an external_select block (app.options) - ...baseEvent, - body: { - type: 'block_suggestion', - action_id: 'external_select_action_id', - channel: {}, - user: {}, - team: {}, - actions: [], - }, - }, - { - // IncomingEventType.Action for "data_source": "external" in dialogs (app.options) - ...baseEvent, - body: { - type: 'dialog_suggestion', - callback_id: 'dialog_suggestion_callback_id', - name: 'the name', - channel: {}, - user: {}, - team: {}, - }, - }, - { - // IncomingEventType.ViewSubmitAction (app.view) - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: null, - enterprise: {}, - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - enterprise: {}, - // Although {team: undefined} pattern does not exist as of Jan 2021, - // this test verifies if App works even if the field is missing. - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: {}, - // Although {enterprise: undefined} pattern does not exist as of Jan 2021, - // this test verifies if App works even if the field is missing. - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_closed', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'message', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - }, - }, - }, - ]; - } - - function createOrgAppReceiverEvents(): ReceiverEvent[] { - return [ - { - // IncomingEventType.Event (app.event) - ...baseEvent, - body: { - event: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Command (app.command) - ...baseEvent, - body: { - command: '/COMMAND_NAME', - is_enterprise_install: 'true', - enterprise_id: 'E12345678', - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'block_actions', - actions: [ - { - action_id: 'block_action_id', - }, - ], - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'message_action_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'message_action', - callback_id: 'another_message_action_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Shortcut (app.shortcut) - ...baseEvent, - body: { - type: 'shortcut', - callback_id: 'another_shortcut_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action (app.action) - ...baseEvent, - body: { - type: 'interactive_message', - callback_id: 'interactive_message_callback_id', - actions: [{}], - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action with dialog submission (app.action) - ...baseEvent, - body: { - type: 'dialog_submission', - callback_id: 'dialog_submission_callback_id', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action for an external_select block (app.options) - ...baseEvent, - body: { - type: 'block_suggestion', - action_id: 'external_select_action_id', - channel: {}, - user: {}, - team: {}, - actions: [], - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.Action for "data_source": "external" in dialogs (app.options) - ...baseEvent, - body: { - type: 'dialog_suggestion', - callback_id: 'dialog_suggestion_callback_id', - name: 'the name', - channel: {}, - user: {}, - team: {}, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - // IncomingEventType.ViewSubmitAction (app.view) - ...baseEvent, - body: { - type: 'view_submission', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'view_closed', - channel: {}, - user: {}, - team: {}, - view: { - callback_id: 'view_callback_id', - }, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - { - ...baseEvent, - body: { - type: 'event_callback', - token: 'XXYYZZ', - team_id: 'TXXXXXXXX', - api_app_id: 'AXXXXXXXXX', - event: { - type: 'message', - event_ts: '1234567890.123456', - user: 'UXXXXXXX1', - text: 'hello friends!', - }, - is_enterprise_install: true, - enterprise: { - id: 'E12345678', - }, - }, - }, - ]; - } - - it('should acknowledge any of possible events', async () => { - // Arrange - const ackFn = sinon.fake.resolves({}); - const actionFn = sinon.fake.resolves({}); - const shortcutFn = sinon.fake.resolves({}); - const viewFn = sinon.fake.resolves({}); - const optionsFn = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - const dummyReceiverEvents = createReceiverEvents(); - - // Act - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - - app.use(async ({ next }) => { - await ackFn(); - await next(); - }); - app.shortcut({ callback_id: 'message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'another_message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.shortcut({ callback_id: 'shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'another_shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.action('block_action_id', async () => { - await actionFn(); - }); - app.action({ callback_id: 'interactive_message_callback_id' }, async () => { - await actionFn(); - }); - app.action({ callback_id: 'dialog_submission_callback_id' }, async () => { - await actionFn(); - }); - app.view('view_callback_id', async () => { - await viewFn(); - }); - app.view({ callback_id: 'view_callback_id', type: 'view_closed' }, async () => { - await viewFn(); - }); - app.options('external_select_action_id', async () => { - await optionsFn(); - }); - app.options({ - type: 'block_suggestion', - action_id: 'external_select_action_id', - }, async () => { - await optionsFn(); - }); - app.options({ callback_id: 'dialog_suggestion_callback_id' }, async () => { - await optionsFn(); - }); - app.options({ - type: 'dialog_suggestion', - callback_id: 'dialog_suggestion_callback_id', - }, async () => { - await optionsFn(); - }); - - app.event('app_home_opened', noop); - app.event(/app_home_opened|app_mention/, noop); - app.message('hello', noop); - app.command('/echo', noop); - app.command(/\/e.*/, noop); - - // invalid view constraints - const invalidViewConstraints1 = { - callback_id: 'foo', - type: 'view_submission', - unknown_key: 'should be detected', - } as any as ViewConstraints; - app.view(invalidViewConstraints1, noop); - assert.isTrue(fakeLogger.error.called); - - fakeLogger.error = sinon.fake(); - - const invalidViewConstraints2 = { - callback_id: 'foo', - type: undefined, - unknown_key: 'should be detected', - } as any as ViewConstraints; - app.view(invalidViewConstraints2, noop); - assert.isTrue(fakeLogger.error.called); - - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(actionFn.callCount, 3); - assert.equal(shortcutFn.callCount, 4); - assert.equal(viewFn.callCount, 5); - assert.equal(optionsFn.callCount, 4); - assert.equal(ackFn.callCount, dummyReceiverEvents.length); - assert(fakeErrorHandler.notCalled); - }); - - // This test confirms authorize is being used for org events - it('should acknowledge any possible org events', async () => { - // Arrange - const ackFn = sinon.fake.resolves({}); - const actionFn = sinon.fake.resolves({}); - const shortcutFn = sinon.fake.resolves({}); - const viewFn = sinon.fake.resolves({}); - const optionsFn = sinon.fake.resolves({}); - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - const dummyReceiverEvents = createOrgAppReceiverEvents(); - - // Act - const fakeLogger = createFakeLogger(); - const app = new MockApp({ - logger: fakeLogger, - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - - app.use(async ({ next }) => { - await ackFn(); - await next(); - }); - app.shortcut({ callback_id: 'message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'another_message_action_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'message_action', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.shortcut({ callback_id: 'shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'another_shortcut_callback_id' }, async () => { - await shortcutFn(); - }); - app.shortcut({ type: 'shortcut', callback_id: 'does_not_exist' }, async () => { - await shortcutFn(); - }); - app.action('block_action_id', async () => { - await actionFn(); - }); - app.action({ callback_id: 'interactive_message_callback_id' }, async () => { - await actionFn(); - }); - app.action({ callback_id: 'dialog_submission_callback_id' }, async () => { - await actionFn(); - }); - app.view('view_callback_id', async () => { - await viewFn(); - }); - app.view({ callback_id: 'view_callback_id', type: 'view_closed' }, async () => { - await viewFn(); - }); - app.options('external_select_action_id', async () => { - await optionsFn(); - }); - app.options({ callback_id: 'dialog_suggestion_callback_id' }, async () => { - await optionsFn(); - }); - - app.event('app_home_opened', noop); - app.message('hello', noop); - app.command('/echo', noop); - - // invalid view constraints - const invalidViewConstraints1 = { - callback_id: 'foo', - type: 'view_submission', - unknown_key: 'should be detected', - } as any as ViewConstraints; - app.view(invalidViewConstraints1, noop); - assert.isTrue(fakeLogger.error.called); - - fakeLogger.error = sinon.fake(); - - const invalidViewConstraints2 = { - callback_id: 'foo', - type: undefined, - unknown_key: 'should be detected', - } as any as ViewConstraints; - app.view(invalidViewConstraints2, noop); - assert.isTrue(fakeLogger.error.called); - - app.error(fakeErrorHandler); - await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); - - // Assert - assert.equal(actionFn.callCount, 3); - assert.equal(shortcutFn.callCount, 4); - assert.equal(viewFn.callCount, 2); - assert.equal(optionsFn.callCount, 2); - assert.equal(ackFn.callCount, dummyReceiverEvents.length); - assert(fakeErrorHandler.notCalled); - }); - }); - - describe('App#command patterns', () => { - it('should respond to exact name matches', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let matchCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command('/hello', async () => { - matchCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(matchCount, 1); - }); - - it('should respond to pattern matches', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let matchCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command(/h.*/, async () => { - matchCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(matchCount, 1); - }); - - it('should run all matching listeners', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let firstCount = 0; - let secondCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command(/h.*/, async () => { - firstCount += 1; - }); - app.command(/he.*/, async () => { - secondCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(firstCount, 1); - assert.equal(secondCount, 1); - }); - - it('should not stop at an unsuccessful match', async () => { - // Arrange - overrides = buildOverrides([withNoopWebClient()]); - const MockApp = await importApp(overrides); - let firstCount = 0; - let secondCount = 0; - - // Act - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.command(/x.*/, async () => { - firstCount += 1; - }); - app.command(/h.*/, async () => { - secondCount += 1; - }); - await fakeReceiver.sendEvent({ - body: { - type: 'slash_command', - command: '/hello', - }, - ack: noop, - }); - - // Assert - assert.equal(firstCount, 0); - assert.equal(secondCount, 1); - }); - }); - - describe('App#message patterns', () => { - let fakeMiddleware1: sinon.SinonSpy; - let fakeMiddleware2: sinon.SinonSpy; - let fakeMiddlewares: sinon.SinonSpy[]; - let passFilter: sinon.SinonSpy; - let failFilter: sinon.SinonSpy; - let MockApp: typeof import('./App').default; - let app: App; - - const callNextMiddleware = () => async ({ next }: { next?: NextFn }) => { - if (next) { - await next(); - } - }; - - const fakeMessageEvent = (receiver: FakeReceiver, message: string): Promise => receiver.sendEvent({ - body: { - type: 'event_callback', - event: { - type: 'message', - text: message, - }, - }, - ack: noop, - }); - - const controlledMiddleware = (shouldCallNext: boolean) => async ({ next }: { next?: NextFn }) => { - if (next && shouldCallNext) { - await next(); - } - }; - - const assertMiddlewaresCalledOnce = () => { - assert(fakeMiddleware1.calledOnce); - assert(fakeMiddleware2.calledOnce); - }; - - const assertMiddlewaresCalledOrder = () => { - sinon.assert.callOrder(...fakeMiddlewares); - }; - - const assertMiddlewaresNotCalled = () => { - assert(fakeMiddleware1.notCalled); - assert(fakeMiddleware2.notCalled); - }; - - const message = 'val - pass-string - val'; - const PASS_STRING = 'pass-string'; - const PASS_PATTERN = /.*pass-string.*/; - const FAIL_STRING = 'fail-string'; - const FAIL_PATTERN = /.*fail-string.*/; - - beforeEach(async () => { - sinon.restore(); - MockApp = await importApp(); - app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - fakeMiddleware1 = sinon.spy(callNextMiddleware()); - fakeMiddleware2 = sinon.spy(callNextMiddleware()); - fakeMiddlewares = [ - fakeMiddleware1, - fakeMiddleware2, - ]; - - passFilter = sinon.spy(controlledMiddleware(true)); - failFilter = sinon.spy(controlledMiddleware(false)); - }); - - // public message(...listeners: MessageEventMiddleware[]): void; - it('overload1 - should accept list of listeners and call each one', async () => { - // Act - app.message(...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, 'testing message'); - - // Assert - assertMiddlewaresCalledOnce(); - }); - - it('overload1 - should not call second listener if first does not pass', async () => { - // Act - app.message(controlledMiddleware(false), fakeMiddleware1); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assert(fakeMiddleware1.notCalled); - }); - - // public message(pattern: string | RegExp, ...listeners: MessageEventMiddleware[]): void; - it('overload2 - should call listeners if message contains string', async () => { - // Act - app.message(PASS_STRING, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('overload2 - should not call listeners if message does not contain string', async () => { - // Act - app.message(FAIL_STRING, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload2 - should call listeners if message matches pattern', async () => { - // Act - app.message(PASS_PATTERN, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('overload2 - should not call listeners if message does not match pattern', async () => { - // Act - app.message(FAIL_PATTERN, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload3 - should call listeners if filter and string match', async () => { - // Act - app.message(passFilter, PASS_STRING, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('overload3 - should not call listeners if filter does not pass', async () => { - // Act - app.message(failFilter, PASS_STRING, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload3 - should not call listeners if string does not match', async () => { - // Act - app.message(passFilter, FAIL_STRING, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload3 - should not call listeners if message does not match pattern', async () => { - // Act - app.message(passFilter, FAIL_PATTERN, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('overload4 - should call listeners if filter passes', async () => { - // Act - app.message(passFilter, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOrder(); - assertMiddlewaresCalledOnce(); - }); - - it('overload4 - should not call listeners if filter fails', async () => { - // Act - app.message(failFilter, fakeMiddleware1, fakeMiddleware2); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - - it('should accept multiple strings', async () => { - // Act - app.message(PASS_STRING, '- val', ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('should accept string and pattern', async () => { - // Act - app.message(PASS_STRING, PASS_PATTERN, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - // Assert - assertMiddlewaresCalledOnce(); - assertMiddlewaresCalledOrder(); - }); - - it('should not call listeners after fail', async () => { - // Act - app.message(PASS_STRING, FAIL_PATTERN, ...fakeMiddlewares); - app.message(FAIL_STRING, PASS_PATTERN, ...fakeMiddlewares); - app.message(passFilter, failFilter, ...fakeMiddlewares); - await fakeMessageEvent(fakeReceiver, message); - - // Assert - assertMiddlewaresNotCalled(); - }); - }); - - describe('Quick type compatibility checks', () => { - it('app.view ack() method can compile with minimum inputs', async () => { - const MockApp = await importApp(buildOverrides([withNoopWebClient()])); - const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); - app.view('callback_id', async ({ ack }) => { - await ack({ - response_action: 'push', - view: { - type: 'modal', - title: { - type: 'plain_text', - text: 'Title', - }, - blocks: [], - }, - }); - }); - }); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} - -function withMemoryStore(spy: SinonSpy): Override { - return { - './conversation-store': { - MemoryStore: spy, - }, - }; -} - -function withConversationContext(spy: SinonSpy): Override { - return { - './conversation-store': { - conversationContext: spy, - }, - }; -} diff --git a/src/App-workflow-steps.spec.ts b/src/App-workflow-steps.spec.ts deleted file mode 100644 index d62bd9c2b..000000000 --- a/src/App-workflow-steps.spec.ts +++ /dev/null @@ -1,89 +0,0 @@ -import 'mocha'; -import sinon from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Override, mergeOverrides } from './test-helpers'; -import { - Receiver, - ReceiverEvent, -} from './types'; -import App from './App'; -import { WorkflowStep } from './WorkflowStep'; - -// Fakes -class FakeReceiver implements Receiver { - private bolt: App | undefined; - - public init = (bolt: App) => { - this.bolt = bolt; - }; - - public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); - - public async sendEvent(event: ReceiverEvent): Promise { - return this.bolt?.processEvent(event); - } -} - -describe('App WorkflowStep middleware', () => { - let fakeReceiver: FakeReceiver; - let dummyAuthorizationResult: { botToken: string; botId: string }; - - beforeEach(() => { - fakeReceiver = new FakeReceiver(); - dummyAuthorizationResult = { botToken: '', botId: '' }; - }); - - let app: App; - - beforeEach(async () => { - const MockAppNoOverrides = await importApp(); - app = new MockAppNoOverrides({ - receiver: fakeReceiver, - authorize: sinon.fake.resolves(dummyAuthorizationResult), - }); - }); - - it('should add a listener to middleware for each WorkflowStep passed to app.step', async () => { - const ws = new WorkflowStep('test_id', { edit: [], save: [], execute: [] }); - - /* middleware is a private property on App. Since app.step relies on app.use, - and app.use is fully tested above, we're opting just to ensure that the step listener - is added to the global middleware array, rather than repeating the same tests. */ - const { middleware } = (app as any); - - assert.equal(middleware.length, 2); - - app.step(ws); - - assert.equal(middleware.length, 3); - }); -}); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importApp( - overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), -): Promise { - return (await rewiremock.module(() => import('./App'), overrides)).default; -} - -// Composable overrides -function withNoopWebClient(): Override { - return { - '@slack/web-api': { - WebClient: class {}, - }, - }; -} - -function withNoopAppMetadata(): Override { - return { - '@slack/web-api': { - addAppMetadata: sinon.fake(), - }, - }; -} diff --git a/src/App.ts b/src/App.ts index 9b09975e7..f346a11da 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1,81 +1,103 @@ -import { Agent } from 'http'; -import { SecureContextOptions } from 'tls'; -import util from 'util'; -import { WebClient, ChatPostMessageArguments, addAppMetadata, WebClientOptions } from '@slack/web-api'; -import { Logger, LogLevel, ConsoleLogger } from '@slack/logger'; -import axios, { AxiosInstance, AxiosResponse } from 'axios'; -import SocketModeReceiver from './receivers/SocketModeReceiver'; -import HTTPReceiver, { HTTPReceiverOptions } from './receivers/HTTPReceiver'; +import type { Agent } from 'node:http'; +import type { SecureContextOptions } from 'node:tls'; +import util from 'node:util'; +import { ConsoleLogger, LogLevel, type Logger } from '@slack/logger'; +import { type ChatPostMessageArguments, WebClient, type WebClientOptions, addAppMetadata } from '@slack/web-api'; +import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; +import type { Assistant } from './Assistant'; +import { + CustomFunction, + type CustomFunctionMiddleware, + type FunctionCompleteFn, + type FunctionFailFn, +} from './CustomFunction'; +import type { WorkflowStep } from './WorkflowStep'; +import { type ConversationStore, MemoryStore, conversationContext } from './conversation-store'; +import { + AppInitializationError, + type CodedError, + ErrorCode, + InvalidCustomPropertyError, + MultipleListenerError, + asCodedError, +} from './errors'; +import { + IncomingEventType, + assertNever, + getTypeAndConversation, + isBodyWithTypeEnterpriseInstall, + isEventTypeToSkipAuthorize, +} from './helpers'; import { ignoreSelf as ignoreSelfMiddleware, - onlyActions, + matchCommandName, matchConstraints, + matchEventType, + matchMessage, + onlyActions, onlyCommands, - matchCommandName, + onlyEvents, onlyOptions, onlyShortcuts, - onlyEvents, - matchEventType, - matchMessage, onlyViewActions, } from './middleware/builtin'; import processMiddleware from './middleware/process'; -import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; -import { WorkflowStep } from './WorkflowStep'; -import { - Middleware, +import HTTPReceiver, { type HTTPReceiverOptions } from './receivers/HTTPReceiver'; +import SocketModeReceiver from './receivers/SocketModeReceiver'; +import type { + AckFn, + ActionConstraints, + AllMiddlewareArgs, AnyMiddlewareArgs, + BlockAction, + BlockElementAction, + Context, + DialogSubmitAction, + EventTypePattern, + FunctionInputs, + InteractiveAction, + InteractiveMessage, + KnownEventFromType, + KnownOptionsPayloadFromType, + Middleware, + OptionsConstraints, + OptionsSource, + Receiver, + ReceiverEvent, + RespondArguments, + RespondFn, + SayFn, + ShortcutConstraints, + SlackAction, SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, - SlackShortcutMiddlewareArgs, - SlackViewMiddlewareArgs, - SlackAction, - EventTypePattern, SlackShortcut, - Context, - SayFn, - AckFn, - RespondFn, - OptionsSource, - BlockAction, - InteractiveMessage, + SlackShortcutMiddlewareArgs, SlackViewAction, - Receiver, - ReceiverEvent, - RespondArguments, - DialogSubmitAction, - BlockElementAction, - InteractiveAction, - ViewOutput, - KnownOptionsPayloadFromType, - KnownEventFromType, + SlackViewMiddlewareArgs, SlashCommand, + ViewConstraints, + ViewOutput, WorkflowStepEdit, - SlackOptions, - FunctionInputs, } from './types'; -import { IncomingEventType, getTypeAndConversation, assertNever, isBodyWithTypeEnterpriseInstall, isEventTypeToSkipAuthorize } from './helpers'; -import { CodedError, asCodedError, AppInitializationError, MultipleListenerError, ErrorCode, InvalidCustomPropertyError } from './errors'; -import { AllMiddlewareArgs, contextBuiltinKeys } from './types/middleware'; -import { StringIndexed } from './types/helpers'; -// eslint-disable-next-line import/order -import allSettled = require('promise.allsettled'); // eslint-disable-line @typescript-eslint/no-require-imports -import { FunctionCompleteFn, FunctionFailFn, CustomFunction, CustomFunctionMiddleware } from './CustomFunction'; -import { Assistant } from './Assistant'; -// eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-commonjs -const packageJson = require('../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires +import { contextBuiltinKeys } from './types'; +import { type StringIndexed, isRejected } from './types/utilities'; +const packageJson = require('../package.json'); + +export type { ActionConstraints, OptionsConstraints, ShortcutConstraints, ViewConstraints } from './types'; // ---------------------------- // For listener registration methods - +// TODO: we have types for this... consolidate const validViewTypes = ['view_closed', 'view_submission']; // ---------------------------- // For the constructor -const tokenUsage = 'Apps used in a single workspace can be initialized with a token. Apps used in many workspaces ' + +const tokenUsage = + 'Apps used in a single workspace can be initialized with a token. Apps used in many workspaces ' + 'should be initialized with oauth installer options or authorize.'; /** App initialization options */ @@ -89,7 +111,7 @@ export interface AppOptions { clientId?: HTTPReceiverOptions['clientId']; clientSecret?: HTTPReceiverOptions['clientSecret']; stateSecret?: HTTPReceiverOptions['stateSecret']; // required when using default stateStore - redirectUri?: HTTPReceiverOptions['redirectUri'] + redirectUri?: HTTPReceiverOptions['redirectUri']; installationStore?: HTTPReceiverOptions['installationStore']; // default MemoryInstallationStore scopes?: HTTPReceiverOptions['scopes']; installerOptions?: HTTPReceiverOptions['installerOptions']; @@ -117,9 +139,10 @@ export interface AppOptions { export { LogLevel, Logger } from '@slack/logger'; /** Authorization function - seeds the middleware processing and listeners with an authorization context */ -export interface Authorize { - (source: AuthorizeSourceData, body?: AnyMiddlewareArgs['body']): Promise; -} +export type Authorize = ( + source: AuthorizeSourceData, + body?: AnyMiddlewareArgs['body'], +) => Promise; /** Authorization function inputs - authenticated data about an event for the authorization function */ export interface AuthorizeSourceData { @@ -140,38 +163,10 @@ export interface AuthorizeResult { userId?: string; teamId?: string; enterpriseId?: string; - // TODO: for better type safety, we may want to revisit this - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: TODO: for better type safety, we may want to revisit this [key: string]: any; } -export interface ActionConstraints { - type?: A['type']; - block_id?: A extends BlockAction ? string | RegExp : never; - action_id?: A extends BlockAction ? string | RegExp : never; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback_id?: Extract extends any ? string | RegExp : never; -} - -// TODO: more strict typing to allow block/action_id for block_suggestion etc. -export interface OptionsConstraints { - type?: A['type']; - block_id?: A extends SlackOptions ? string | RegExp : never; - action_id?: A extends SlackOptions ? string | RegExp : never; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - callback_id?: Extract extends any ? string | RegExp : never; -} - -export interface ShortcutConstraints { - type?: S['type']; - callback_id?: string | RegExp; -} - -export interface ViewConstraints { - callback_id?: string | RegExp; - type?: 'view_closed' | 'view_submission'; -} - // Passed internally to the handleError method interface AllErrorHandlerArgs { error: Error; // Error is not necessarily a CodedError @@ -185,21 +180,17 @@ export interface ExtendedErrorHandlerArgs extends AllErrorHandlerArgs { error: CodedError; // asCodedError has been called } -export interface ErrorHandler { - (error: CodedError): Promise; -} +export type ErrorHandler = (error: CodedError) => Promise; -export interface ExtendedErrorHandler { - (args: ExtendedErrorHandlerArgs): Promise; -} +export type ExtendedErrorHandler = (args: ExtendedErrorHandlerArgs) => Promise; -export interface AnyErrorHandler extends ErrorHandler, ExtendedErrorHandler { -} +export interface AnyErrorHandler extends ErrorHandler, ExtendedErrorHandler {} // Used only in this file -type MessageEventMiddleware< - CustomContext extends StringIndexed = StringIndexed, -> = Middleware, CustomContext>; +type MessageEventMiddleware = Middleware< + SlackEventMiddlewareArgs<'message'>, + CustomContext +>; class WebClientPool { private pool: { [token: string]: WebClient } = {}; @@ -390,8 +381,8 @@ export default class App this.developerMode && this.installerOptions && (typeof this.installerOptions.callbackOptions === 'undefined' || - (typeof this.installerOptions.callbackOptions !== 'undefined' && - typeof this.installerOptions.callbackOptions.failure === 'undefined')) + (typeof this.installerOptions.callbackOptions !== 'undefined' && + typeof this.installerOptions.callbackOptions.failure === 'undefined')) ) { // add a custom failure callback for Developer Mode in case they are using OAuth this.logger.debug('adding Developer Mode custom OAuth failure handler'); @@ -439,17 +430,13 @@ export default class App this.initialized = false; // You need to run `await app.init();` on your own } else { - this.authorize = this.initAuthorizeInConstructor( - token, - authorize, - argAuthorization, - ); + this.authorize = this.initAuthorizeInConstructor(token, authorize, argAuthorization); this.initialized = true; } // Conditionally use a global middleware that ignores events (including messages) that are sent from this app if (ignoreSelf) { - this.use(ignoreSelfMiddleware()); + this.use(ignoreSelfMiddleware); } // Use conversation state global middleware @@ -467,10 +454,7 @@ export default class App public async init(): Promise { this.initialized = true; try { - const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven( - this.argToken, - this.argAuthorize, - ); + const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven(this.argToken, this.argAuthorize); if (initializedAuthorize !== undefined) { this.authorize = initializedAuthorize; return; @@ -487,14 +471,12 @@ export default class App }; } } - this.authorize = singleAuthorization( - this.client, - authorization, - this.tokenVerificationEnabled, - ); + this.authorize = singleAuthorization(this.client, authorization, this.tokenVerificationEnabled); this.initialized = true; } else { - this.logger.error('Something has gone wrong. Please report this issue to the maintainers. https://github.com/slackapi/bolt-js/issues'); + this.logger.error( + 'Something has gone wrong. Please report this issue to the maintainers. https://github.com/slackapi/bolt-js/issues', + ); assertNever(); } } catch (e) { @@ -535,6 +517,8 @@ export default class App * 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(); @@ -543,8 +527,8 @@ export default class App } /** - * Register CustomFunction middleware - */ + * Register CustomFunction middleware + */ public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this { const fn = new CustomFunction(callbackId, listeners, this.webClientOptions); const m = fn.getMiddleware(); @@ -571,49 +555,42 @@ export default class App return this.receiver.start(...args) as ReturnType; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: receivers could accept anything as arguments for stop public stop(...args: any[]): Promise { return this.receiver.stop(...args); } - public event< - EventType extends string = string, - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + // TODO: can constrain EventType here to the set of available slack event types to help autocomplete event names + public event( eventName: EventType, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; - public event< - EventType extends RegExp = RegExp, - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public event( eventName: EventType, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; public event< - EventType extends EventTypePattern = EventTypePattern, - MiddlewareCustomContext extends StringIndexed = StringIndexed, + EventType extends EventTypePattern = EventTypePattern, + MiddlewareCustomContext extends StringIndexed = StringIndexed, >( eventNameOrPattern: EventType, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void { let invalidEventName = false; if (typeof eventNameOrPattern === 'string') { - const name = eventNameOrPattern as string; + const name = eventNameOrPattern; invalidEventName = name.startsWith('message.'); } else if (eventNameOrPattern instanceof RegExp) { - const name = (eventNameOrPattern as RegExp).source; + const name = eventNameOrPattern.source; invalidEventName = name.startsWith('message\\.'); } if (invalidEventName) { throw new AppInitializationError( - `Although the document mentions "${eventNameOrPattern}",` + - 'it is not a valid event type. Use "message" instead. ' + - 'If you want to filter message events, you can use event.channel_type for it.', + `Although the document mentions "${eventNameOrPattern}", it is not a valid event type. Use "message" instead. If you want to filter message events, you can use event.channel_type for it.`, ); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyEvents, matchEventType(eventNameOrPattern), @@ -625,18 +602,16 @@ export default class App * * @param listeners Middlewares that process and react to a message event */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >(...listeners: MessageEventMiddleware[]): void; + public message( + ...listeners: MessageEventMiddleware[] + ): void; /** * * @param pattern Used for filtering out messages that don't match. * Strings match via {@link String.prototype.includes}. * @param listeners Middlewares that process and react to the message events that matched the provided patterns. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public message( pattern: string | RegExp, ...listeners: MessageEventMiddleware[] ): void; @@ -648,9 +623,7 @@ export default class App * via {@link String.prototype.includes}. * @param listeners Middlewares that process and react to the message events that matched the provided pattern. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public message( filter: MessageEventMiddleware, pattern: string | RegExp, ...listeners: MessageEventMiddleware[] @@ -661,10 +634,8 @@ export default class App * {@link AllMiddlewareArgs.next} if there is no match. See {@link directMention} for an example. * @param listeners Middlewares that process and react to the message events that matched the provided patterns. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( - filter: MessageEventMiddleware, + public message( + filter: MessageEventMiddleware, // TODO: why do we need this override? shouldnt ...listeners capture this too? ...listeners: MessageEventMiddleware[] ): void; /** @@ -673,14 +644,11 @@ export default class App * all remaining patterns and middlewares will be skipped. * @param patternsOrMiddleware A mix of patterns and/or middlewares. */ - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + public message( ...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware)[] ): void; - public message< - MiddlewareCustomContext extends StringIndexed = StringIndexed, - >( + // TODO: expose a type parameter for overriding the MessageEvent type (just like shortcut() and action() does) https://github.com/slackapi/bolt-js/issues/796 + public message( ...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware)[] ): void { const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => { @@ -688,8 +656,8 @@ export default class App return matchMessage(patternOrMiddleware); } return patternOrMiddleware; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + }) as any; this.listeners.push([ onlyEvents, @@ -711,7 +679,10 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( constraints: Constraints, - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackShortcutMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void; public shortcut< Shortcut extends SlackShortcut = SlackShortcut, @@ -719,23 +690,28 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( callbackIdOrConstraints: string | RegExp | Constraints, - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackShortcutMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void { - const constraints: ShortcutConstraints = typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) ? - { callback_id: callbackIdOrConstraints } : - callbackIdOrConstraints; + const constraints: ShortcutConstraints = + typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) + ? { callback_id: callbackIdOrConstraints } + : callbackIdOrConstraints; // Fail early if the constraints contain invalid keys const unknownConstraintKeys = Object.keys(constraints).filter((k) => k !== 'callback_id' && k !== 'type'); if (unknownConstraintKeys.length > 0) { + // TODO:event() will throw an error if you provide an invalid event name; we should align this behaviour. this.logger.error( `Slack listener cannot be attached using unknown constraint keys: ${unknownConstraintKeys.join(', ')}`, ); return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyShortcuts, matchConstraints(constraints), @@ -743,8 +719,6 @@ export default class App ] as Middleware[]); } - // NOTE: this is what's called a convenience generic, so that types flow more easily without casting. - // https://web.archive.org/web/20210629110615/https://basarat.gitbook.io/typescript/type-system/generics#motivation-and-samples public action< Action extends SlackAction = SlackAction, MiddlewareCustomContext extends StringIndexed = StringIndexed, @@ -759,7 +733,10 @@ export default class App >( constraints: Constraints, // NOTE: Extract<> is able to return the whole union when type: undefined. Why? - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackActionMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void; public action< Action extends SlackAction = SlackAction, @@ -767,37 +744,40 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( actionIdOrConstraints: string | RegExp | Constraints, - ...listeners: Middleware>, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackActionMiddlewareArgs>, + AppCustomContext & MiddlewareCustomContext + >[] ): void { // Normalize Constraints - const constraints: ActionConstraints = typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) ? - { action_id: actionIdOrConstraints } : - actionIdOrConstraints; + const constraints: ActionConstraints = + typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) + ? { action_id: actionIdOrConstraints } + : actionIdOrConstraints; // Fail early if the constraints contain invalid keys const unknownConstraintKeys = Object.keys(constraints).filter( (k) => k !== 'action_id' && k !== 'block_id' && k !== 'callback_id' && k !== 'type', ); if (unknownConstraintKeys.length > 0) { + // TODO:event() will throw an error if you provide an invalid event name; we should align this behaviour. this.logger.error( `Action listener cannot be attached using unknown constraint keys: ${unknownConstraintKeys.join(', ')}`, ); return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([onlyActions, matchConstraints(constraints), ..._listeners] as Middleware[]); } public command( - commandName: string | RegExp, ...listeners: Middleware< - SlackCommandMiddlewareArgs, - AppCustomContext & MiddlewareCustomContext - >[] + commandName: string | RegExp, + ...listeners: Middleware[] ): void { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyCommands, matchCommandName(commandName), @@ -806,18 +786,18 @@ export default class App } public options< - Source extends OptionsSource = 'block_suggestion', + Source extends OptionsSource = 'block_suggestion', // TODO: here, similarly to `message()`, the generic is the string `type` of the payload. in others, like `action()`, it's the entire payload. could we make this consistent? MiddlewareCustomContext extends StringIndexed = StringIndexed, >( actionId: string | RegExp, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; - // TODO: reflect the type in constraints to Source + // TODO: reflect the type in constraints to Source (this relates to the above TODO, too) public options< Source extends OptionsSource = OptionsSource, MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - constraints: OptionsConstraints, + constraints: OptionsConstraints, // TODO: to be able to 'link' listener arguments to the constrains, should pass the Source type in as a generic here ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void; // TODO: reflect the type in constraints to Source @@ -828,13 +808,13 @@ export default class App actionIdOrConstraints: string | RegExp | OptionsConstraints, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void { - const constraints: OptionsConstraints = typeof actionIdOrConstraints === 'string' || - util.types.isRegExp(actionIdOrConstraints) ? - { action_id: actionIdOrConstraints } : - actionIdOrConstraints; + const constraints: OptionsConstraints = + typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints) + ? { action_id: actionIdOrConstraints } + : actionIdOrConstraints; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([onlyOptions, matchConstraints(constraints), ..._listeners] as Middleware[]); } @@ -847,6 +827,7 @@ export default class App ): void; public view< ViewActionType extends SlackViewAction = SlackViewAction, + // TODO: add a type parameter for view constraints; this way we can constrain the handler view arguments based on the type of the constraint, similar to what action() does MiddlewareCustomContext extends StringIndexed = StringIndexed, >( constraints: ViewConstraints, @@ -859,9 +840,10 @@ export default class App callbackIdOrConstraints: string | RegExp | ViewConstraints, ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] ): void { - const constraints: ViewConstraints = typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) ? - { callback_id: callbackIdOrConstraints, type: 'view_submission' } : - callbackIdOrConstraints; + const constraints: ViewConstraints = + typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints) + ? { callback_id: callbackIdOrConstraints, type: 'view_submission' } + : callbackIdOrConstraints; // Fail early if the constraints contain invalid keys const unknownConstraintKeys = Object.keys(constraints).filter((k) => k !== 'callback_id' && k !== 'type'); if (unknownConstraintKeys.length > 0) { @@ -876,8 +858,8 @@ export default class App return; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const _listeners = listeners as any; // FIXME: workaround for TypeScript 4.7 breaking changes + // biome-ignore lint/suspicious/noExplicitAny: FIXME: workaround for TypeScript 4.7 breaking changes + const _listeners = listeners as any; this.listeners.push([ onlyViewActions, matchConstraints(constraints), @@ -933,12 +915,10 @@ export default class App try { authorizeResult = await this.authorize(source, bodyArg); } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; this.logger.warn('Authorization of incoming event did not succeed. No listeners will be called.'); e.code = ErrorCode.AuthorizationError; - // disabling due to https://github.com/typescript-eslint/typescript-eslint/issues/1277 - // eslint-disable-next-line consistent-return return this.handleError({ error: e, logger: this.logger, @@ -985,21 +965,28 @@ export default class App const { functionExecutionId, functionBotAccessToken, functionInputs } = extractFunctionContext(body); if (functionExecutionId) { context.functionExecutionId = functionExecutionId; - if (functionInputs) { context.functionInputs = functionInputs; } + if (functionInputs) { + context.functionInputs = functionInputs; + } } // Attach and make available the JIT/function-related token on context if (this.attachFunctionToken) { - if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } + if (functionBotAccessToken) { + context.functionBotAccessToken = functionBotAccessToken; + } } // Factory for say() utility const createSay = (channelId: string): SayFn => { const token = selectToken(context); - return (message: Parameters[0]) => { - const postMessageArguments: ChatPostMessageArguments = typeof message === 'string' ? - { token, text: message, channel: channelId } : - { ...message, token, channel: channelId }; + return (message) => { + let postMessageArguments: ChatPostMessageArguments; + if (typeof message === 'string') { + postMessageArguments = { token, text: message, channel: channelId }; + } else { + postMessageArguments = { ...message, token, channel: channelId }; + } return this.client.chat.postMessage(postMessageArguments); }; @@ -1007,8 +994,18 @@ export default class App // Set body and payload // TODO: this value should eventually conform to AnyMiddlewareArgs - let payload: DialogSubmitAction | WorkflowStepEdit | SlackShortcut | KnownEventFromType | SlashCommand - | KnownOptionsPayloadFromType | BlockElementAction | ViewOutput | InteractiveAction; + // 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 + | KnownOptionsPayloadFromType + | BlockElementAction + | ViewOutput + | InteractiveAction; switch (type) { case IncomingEventType.Event: payload = (bodyArg as SlackEventMiddlewareArgs['body']).event; @@ -1017,25 +1014,21 @@ export default class App payload = (bodyArg as SlackViewMiddlewareArgs['body']).view; break; case IncomingEventType.Shortcut: - payload = (bodyArg as SlackShortcutMiddlewareArgs['body']); + payload = bodyArg as SlackShortcutMiddlewareArgs['body']; break; - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore: Fallthrough case in switch + // biome-ignore lint/suspicious/noFallthroughSwitchClause: usually not great, but we do it here case IncomingEventType.Action: if (isBlockActionOrInteractiveMessageBody(bodyArg as SlackActionMiddlewareArgs['body'])) { - const { actions } = (bodyArg as SlackActionMiddlewareArgs['body']); + const { actions } = bodyArg as SlackActionMiddlewareArgs['body']; [payload] = actions; break; } - // If above conditional does not hit, fall through to fallback payload in default block below + // If above conditional does not hit, fall through to fallback payload in default block below default: - payload = (bodyArg as ( - | Exclude< - AnyMiddlewareArgs, - SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackViewMiddlewareArgs - > + payload = bodyArg as ( + | Exclude | SlackActionMiddlewareArgs> - )['body']); + )['body']; break; } // NOTE: the following doesn't work because... distributive? @@ -1046,7 +1039,7 @@ export default class App /** Respond function might be set below */ respond?: RespondFn; /** Ack function might be set below */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: different kinds of acks accept different arguments, TODO: revisit this to see if we can type better ack?: AckFn; complete?: FunctionCompleteFn; fail?: FunctionFailFn; @@ -1056,6 +1049,7 @@ export default class App payload, }; + // 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 // Set aliases if (type === IncomingEventType.Event) { const eventListenerArgs = listenerArgs as SlackEventMiddlewareArgs; @@ -1118,12 +1112,11 @@ export default class App } if (token !== undefined) { - let pool; + let pool: WebClientPool | undefined = undefined; const clientOptionsCopy = { ...this.clientOptions }; if (authorizeResult.teamId !== undefined) { pool = this.clients[authorizeResult.teamId]; if (pool === undefined) { - // eslint-disable-next-line no-multi-assign pool = this.clients[authorizeResult.teamId] = new WebClientPool(); } // Add teamId to clientOptions so it can be automatically added to web-api calls @@ -1131,7 +1124,6 @@ export default class App } else if (authorizeResult.enterpriseId !== undefined) { pool = this.clients[authorizeResult.enterpriseId]; if (pool === undefined) { - // eslint-disable-next-line no-multi-assign pool = this.clients[authorizeResult.enterpriseId] = new WebClientPool(); } } @@ -1169,32 +1161,30 @@ export default class App this.logger, // When all the listener middleware are done processing, // `listener` here will be called with a noop `next` fn - async () => listener({ - ...(listenerArgs as AnyMiddlewareArgs), - context, - client, - logger: this.logger, - next: () => {}, - } as AnyMiddlewareArgs & AllMiddlewareArgs), + async () => + listener({ + ...(listenerArgs as AnyMiddlewareArgs), + context, + client, + logger: this.logger, + next: () => {}, + } as AnyMiddlewareArgs & AllMiddlewareArgs), ); }); - const settledListenerResults = await allSettled(listenerResults); - const rejectedListenerResults = settledListenerResults.filter( - (lr) => lr.status === 'rejected', - ) as allSettled.PromiseRejection[]; + const settledListenerResults = await Promise.allSettled(listenerResults); + const rejectedListenerResults = settledListenerResults.filter(isRejected); if (rejectedListenerResults.length === 1) { throw rejectedListenerResults[0].reason; + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (rejectedListenerResults.length > 1) { throw new MultipleListenerError(rejectedListenerResults.map((rlr) => rlr.reason)); } }, ); } catch (error) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const e = error as any; - // disabling due to https://github.com/typescript-eslint/typescript-eslint/issues/1277 - // eslint-disable-next-line consistent-return return this.handleError({ context, error: e, @@ -1210,9 +1200,9 @@ export default class App private handleError(args: AllErrorHandlerArgs): Promise { const { error, ...rest } = args; - return this.extendedErrorHandler && this.hasCustomErrorHandler ? - this.errorHandler({ error: asCodedError(error), ...rest }) : - this.errorHandler(asCodedError(error)); + return this.extendedErrorHandler && this.hasCustomErrorHandler + ? this.errorHandler({ error: asCodedError(error), ...rest }) + : this.errorHandler(asCodedError(error)); } // --------------------- @@ -1245,7 +1235,9 @@ export default class App } if (this.socketMode === true) { if (appToken === undefined) { - throw new AppInitializationError('You must provide an appToken when socketMode is set to true. To generate an appToken see: https://api.slack.com/apis/connections/socket#token'); + throw new AppInitializationError( + 'You must provide an appToken when socketMode is set to true. To generate an appToken see: https://api.slack.com/apis/connections/socket#token', + ); } this.logger.debug('Initializing SocketModeReceiver'); return new SocketModeReceiver({ @@ -1289,16 +1281,10 @@ export default class App }); } - private initAuthorizeIfNoTokenIsGiven( - token?: string, - authorize?: Authorize, - ): Authorize | undefined { + private initAuthorizeIfNoTokenIsGiven(token?: string, authorize?: Authorize): Authorize | undefined { let usingOauth = false; - const httpReceiver = (this.receiver as HTTPReceiver); - if ( - httpReceiver.installer !== undefined && - httpReceiver.installer.authorize !== undefined - ) { + const httpReceiver = this.receiver as HTTPReceiver; + if (httpReceiver.installer !== undefined && httpReceiver.installer.authorize !== undefined) { // This supports using the built-in HTTPReceiver, declaring your own HTTPReceiver // and theoretically, doing a fully custom (non-Express.js) receiver that implements OAuth usingOauth = true; @@ -1317,11 +1303,14 @@ export default class App throw new AppInitializationError( `${tokenUsage} \n\nSince you have not provided a token or authorize, you might be missing one or more required oauth installer options. See https://slack.dev/bolt-js/concepts/authenticating-oauth for these required fields.\n`, ); + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (authorize !== undefined && usingOauth) { throw new AppInitializationError(`You cannot provide both authorize and oauth installer options. ${tokenUsage}`); + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (authorize === undefined && usingOauth) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // biome-ignore lint/style/noNonNullAssertion: we know installer is truthy here return httpReceiver.installer!.authorize; + // biome-ignore lint/style/noUselessElse: I think this is a biome issue actually... } else if (authorize !== undefined && !usingOauth) { return authorize as Authorize; } @@ -1333,19 +1322,12 @@ export default class App authorize?: Authorize, authorization?: Authorization, ): Authorize { - const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven( - token, - authorize, - ); + const initializedAuthorize = this.initAuthorizeIfNoTokenIsGiven(token, authorize); if (initializedAuthorize !== undefined) { return initializedAuthorize; } if (token !== undefined && authorization !== undefined) { - return singleAuthorization( - this.client, - authorization, - this.tokenVerificationEnabled, - ); + return singleAuthorization(this.client, authorization, this.tokenVerificationEnabled); } const hasToken = token !== undefined && token.length > 0; const errorMessage = `Something has gone wrong in #initAuthorizeInConstructor method (hasToken: ${hasToken}, authorize: ${authorize}). Please report this issue to the maintainers. https://github.com/slackapi/bolt-js/issues`; @@ -1370,12 +1352,12 @@ function runAuthTestForBotToken( authorization: Partial & { botToken: Required['botToken'] }, ): Promise<{ botUserId: string; botId: string }> { // TODO: warn when something needed isn't found - return authorization.botUserId !== undefined && authorization.botId !== undefined ? - Promise.resolve({ botUserId: authorization.botUserId, botId: authorization.botId }) : - client.auth.test({ token: authorization.botToken }).then((result) => ({ - botUserId: result.user_id as string, - botId: result.bot_id as string, - })); + return authorization.botUserId !== undefined && authorization.botId !== undefined + ? Promise.resolve({ botUserId: authorization.botUserId, botId: authorization.botId }) + : client.auth.test({ token: authorization.botToken }).then((result) => ({ + botUserId: result.user_id as string, + botId: result.bot_id as string, + })); } // the shortened type, which is supposed to be used only in this source file @@ -1400,9 +1382,8 @@ function singleAuthorization( if (tokenVerificationEnabled) { // call auth.test immediately cachedAuthTestResult = runAuthTestForBotToken(client, authorization); - return async ({ isEnterpriseInstall }) => buildAuthorizeResult( - isEnterpriseInstall, cachedAuthTestResult, authorization, - ); + return async ({ isEnterpriseInstall }) => + buildAuthorizeResult(isEnterpriseInstall, cachedAuthTestResult, authorization); } return async ({ isEnterpriseInstall }) => { // hold off calling auth.test API until the first access to authorize function @@ -1444,11 +1425,7 @@ function buildSource( } const parseTeamId = ( - bodyAs: - | SlackAction - | SlackViewAction - | SlackShortcut - | KnownOptionsPayloadFromType, + bodyAs: SlackAction | SlackViewAction | SlackShortcut | KnownOptionsPayloadFromType, ): string | undefined => { // When the app is installed using org-wide deployment, team property will be null if (typeof bodyAs.team !== 'undefined' && bodyAs.team !== null) { @@ -1614,7 +1591,8 @@ function buildRespondFn( function escapeHtml(input: string | undefined | null): string { if (input) { - return input.replace(/&/g, '&') + return input + .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') @@ -1624,9 +1602,9 @@ function escapeHtml(input: string | undefined | null): string { } function extractFunctionContext(body: StringIndexed) { - let functionExecutionId; - let functionBotAccessToken; - let functionInputs; + let functionExecutionId: string | undefined = undefined; + let functionBotAccessToken: string | undefined = undefined; + let functionInputs: FunctionInputs | undefined = undefined; // function_executed event if (body.event && body.event.type === 'function_executed' && body.event.function_execution_id) { diff --git a/src/Assistant.ts b/src/Assistant.ts index 3ae6d4e75..15e37aa07 100644 --- a/src/Assistant.ts +++ b/src/Assistant.ts @@ -1,25 +1,19 @@ -import { +import type { AssistantThreadsSetStatusResponse, AssistantThreadsSetSuggestedPromptsResponse, AssistantThreadsSetTitleResponse, ChatPostMessageArguments, } from '@slack/web-api'; -import processMiddleware from './middleware/process'; -import { - AllMiddlewareArgs, - AnyMiddlewareArgs, - Middleware, - SayFn, - SlackEventMiddlewareArgs, -} from './types'; -import { AssistantInitializationError, AssistantMissingPropertyError } from './errors'; import { - AssistantThreadContext, - AssistantThreadContextStore, + type AssistantThreadContext, + type AssistantThreadContextStore, DefaultThreadContextStore, - GetThreadContextFn, - SaveThreadContextFn, + type GetThreadContextFn, + type SaveThreadContextFn, } from './AssistantThreadContextStore'; +import { AssistantInitializationError, AssistantMissingPropertyError } from './errors'; +import processMiddleware from './middleware/process'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware, SayFn, SlackEventMiddlewareArgs } from './types'; /** * Configuration object used to instantiate the Assistant @@ -43,13 +37,11 @@ interface AssistantUtilityArgs { setTitle: SetTitleFn; } -interface SetStatusFn { - (status: string): Promise; -} +type SetStatusFn = (status: string) => Promise; -interface SetSuggestedPromptsFn { - (params: SetSuggestedPromptsArguments): Promise; -} +type SetSuggestedPromptsFn = ( + params: SetSuggestedPromptsArguments, +) => Promise; interface SetSuggestedPromptsArguments { prompts: [AssistantPrompt, ...AssistantPrompt[]]; @@ -60,9 +52,7 @@ interface AssistantPrompt { message: string; } -interface SetTitleFn { - (title: string): Promise; -} +type SetTitleFn = (title: string) => Promise; /** * Middleware @@ -81,16 +71,20 @@ export type AssistantMiddlewareArgs = | AssistantThreadContextChangedMiddlewareArgs | AssistantUserMessageMiddlewareArgs; -// TODO : revisit Omit of `say`, as it's added on as part of the enrichment step -export interface AssistantThreadStartedMiddlewareArgs extends - Omit, 'say'>, AssistantUtilityArgs {} -export interface AssistantThreadContextChangedMiddlewareArgs extends - Omit, 'say'>, AssistantUtilityArgs {} -export interface AssistantUserMessageMiddlewareArgs extends - Omit, AssistantUtilityArgs {} - -export type AllAssistantMiddlewareArgs = -T & AllMiddlewareArgs; +// TODO: revisit Omit of `say`, as it's added on as part of the enrichment step +export interface AssistantThreadStartedMiddlewareArgs + extends Omit, 'say'>, + AssistantUtilityArgs {} +export interface AssistantThreadContextChangedMiddlewareArgs + extends Omit, 'say'>, + AssistantUtilityArgs {} +// TODO: extending from SlackEventMiddlewareArgs<'message'> likely insufficient as not all message event payloads contain thread_ts - whereas assistant user message events do. Likely need to narrow this down further. +export interface AssistantUserMessageMiddlewareArgs + extends Omit, 'say'>, + AssistantUtilityArgs {} + +export type AllAssistantMiddlewareArgs = T & + AllMiddlewareArgs; /** Constants */ const ASSISTANT_PAYLOAD_TYPES = new Set(['assistant_thread_started', 'assistant_thread_context_changed', 'message']); @@ -168,10 +162,10 @@ export class Assistant { * */ export function enrichAssistantArgs( threadContextStore: AssistantThreadContextStore, - args: AllAssistantMiddlewareArgs, + args: AllAssistantMiddlewareArgs, // TODO: the type here states that these args already have the assistant utilities present? the type here needs likely changing. ): AllAssistantMiddlewareArgs { const { next: _next, ...assistantArgs } = args; - const preparedArgs = { ...assistantArgs as Exclude, 'next'> }; + const preparedArgs = { ...(assistantArgs as Exclude, 'next'>) }; // Do not pass preparedArgs (ie, do not add utilities to get/save) preparedArgs.getThreadContext = () => threadContextStore.get(args); @@ -206,8 +200,10 @@ export function matchesConstraints(args: AssistantMiddlewareArgs): args is Assis */ export function isAssistantMessage(payload: AnyMiddlewareArgs['payload']): boolean { const isThreadMessage = 'channel' in payload && 'thread_ts' in payload; - const inAssistantContainer = ('channel_type' in payload && payload.channel_type === 'im') && - (!('subtype' in payload) || payload.subtype === 'file_share'); + const inAssistantContainer = + 'channel_type' in payload && + payload.channel_type === 'im' && + (!('subtype' in payload) || payload.subtype === 'file_share' || payload.subtype === undefined); // TODO: undefined subtype is a limitation of message event, needs fixing (see https://github.com/slackapi/node-slack-sdk/issues/1904) return isThreadMessage && inAssistantContainer; } @@ -224,7 +220,9 @@ export function validate(config: AssistantConfig): void { // Check for missing required keys const requiredKeys: (keyof AssistantConfig)[] = ['threadStarted', 'userMessage']; const missingKeys: (keyof AssistantConfig)[] = []; - requiredKeys.forEach((key) => { if (config[key] === undefined) missingKeys.push(key); }); + for (const key of requiredKeys) { + if (config[key] === undefined) missingKeys.push(key); + } if (missingKeys.length > 0) { const errorMsg = `Assistant is missing required keys: ${missingKeys.join(', ')}`; @@ -234,12 +232,12 @@ export function validate(config: AssistantConfig): void { // Ensure a callback or an array of callbacks is present const requiredFns: (keyof AssistantConfig)[] = ['threadStarted', 'userMessage']; if ('threadContextChanged' in config) requiredFns.push('threadContextChanged'); - requiredFns.forEach((fn) => { + for (const fn of requiredFns) { if (typeof config[fn] !== 'function' && !Array.isArray(config[fn])) { const errorMsg = `Assistant ${fn} property must be a function or an array of functions`; throw new AssistantInitializationError(errorMsg); } - }); + } // Validate threadContextStore if (config.threadContextStore) { @@ -252,11 +250,11 @@ export function validate(config: AssistantConfig): void { // Check for missing required keys const requiredContextKeys: (keyof AssistantThreadContextStore)[] = ['get', 'save']; const missingContextKeys: (keyof AssistantThreadContextStore)[] = []; - requiredContextKeys.forEach((k) => { + for (const k of requiredContextKeys) { if (config.threadContextStore && config.threadContextStore[k] === undefined) { missingContextKeys.push(k); } - }); + } if (missingContextKeys.length > 0) { const errorMsg = `threadContextStore is missing required keys: ${missingContextKeys.join(', ')}`; @@ -265,12 +263,12 @@ export function validate(config: AssistantConfig): void { // Ensure properties of context store are functions const requiredStoreFns: (keyof AssistantThreadContextStore)[] = ['get', 'save']; - requiredStoreFns.forEach((fn) => { + for (const fn of requiredStoreFns) { if (config.threadContextStore && typeof config.threadContextStore[fn] !== 'function') { const errorMsg = `threadContextStore ${fn} property must be a function`; throw new AssistantInitializationError(errorMsg); } - }); + } } } @@ -286,9 +284,8 @@ export async function processAssistantMiddleware( const lastCallback = callbacks.pop(); if (lastCallback !== undefined) { - await processMiddleware( - callbacks, args, context, client, logger, - async () => lastCallback({ ...args, context, client, logger }), + await processMiddleware(callbacks, args, context, client, logger, async () => + lastCallback({ ...args, context, client, logger }), ); } } @@ -303,16 +300,12 @@ export async function processAssistantMiddleware( * https://api.slack.com/methods/chat.postMessage */ function createSay(args: AllAssistantMiddlewareArgs): SayFn { - const { - client, - payload, - } = args; + const { client, payload } = args; const { channelId: channel, threadTs: thread_ts } = extractThreadInfo(payload); return (message: Parameters[0]) => { - const postMessageArgument: ChatPostMessageArguments = typeof message === 'string' ? - { text: message, channel, thread_ts } : - { ...message, channel, thread_ts }; + const postMessageArgument: ChatPostMessageArguments = + typeof message === 'string' ? { text: message, channel, thread_ts } : { ...message, channel, thread_ts }; return client.chat.postMessage(postMessageArgument); }; @@ -323,17 +316,15 @@ function createSay(args: AllAssistantMiddlewareArgs): SayFn { * https://api.slack.com/methods/assistant.threads.setStatus */ function createSetStatus(args: AllAssistantMiddlewareArgs): SetStatusFn { - const { - client, - payload, - } = args; + const { client, payload } = args; const { channelId: channel_id, threadTs: thread_ts } = extractThreadInfo(payload); - return (status: Parameters[0]) => client.assistant.threads.setStatus({ - channel_id, - thread_ts, - status, - }); + return (status: Parameters[0]) => + client.assistant.threads.setStatus({ + channel_id, + thread_ts, + status, + }); } /** @@ -341,10 +332,7 @@ function createSetStatus(args: AllAssistantMiddlewareArgs): SetStatusFn { * https://api.slack.com/methods/assistant.threads.setSuggestedPrompts */ function createSetSuggestedPrompts(args: AllAssistantMiddlewareArgs): SetSuggestedPromptsFn { - const { - client, - payload, - } = args; + const { client, payload } = args; const { channelId: channel_id, threadTs: thread_ts } = extractThreadInfo(payload); return (params: Parameters[0]) => { @@ -362,26 +350,28 @@ function createSetSuggestedPrompts(args: AllAssistantMiddlewareArgs): SetSuggest * https://api.slack.com/methods/assistant.threads.setTitle */ function createSetTitle(args: AllAssistantMiddlewareArgs): SetTitleFn { - const { - client, - payload, - } = args; + const { client, payload } = args; const { channelId: channel_id, threadTs: thread_ts } = extractThreadInfo(payload); - return (title: Parameters[0]) => client.assistant.threads.setTitle({ - channel_id, - thread_ts, - title, - }); + return (title: Parameters[0]) => + client.assistant.threads.setTitle({ + channel_id, + thread_ts, + title, + }); } /** * `extractThreadInfo()` parses an incoming payload and returns relevant * details about the thread -*/ -export function extractThreadInfo(payload: AllAssistantMiddlewareArgs['payload']): { channelId: string, threadTs: string, context: AssistantThreadContext } { - let channelId: string = ''; - let threadTs: string = ''; + */ +export function extractThreadInfo(payload: AllAssistantMiddlewareArgs['payload']): { + channelId: string; + threadTs: string; + context: AssistantThreadContext; +} { + let channelId = ''; + let threadTs = ''; let context: AssistantThreadContext = {}; // assistant_thread_started, asssistant_thread_context_changed diff --git a/src/AssistantThreadContextStore.spec.ts b/src/AssistantThreadContextStore.spec.ts deleted file mode 100644 index 158154ac2..000000000 --- a/src/AssistantThreadContextStore.spec.ts +++ /dev/null @@ -1,171 +0,0 @@ -import 'mocha'; -import { assert } from 'chai'; -import sinon from 'sinon'; -import { WebClient } from '@slack/web-api'; -import { DefaultThreadContextStore } from './AssistantThreadContextStore'; -import { AllAssistantMiddlewareArgs, extractThreadInfo } from './Assistant'; - -describe('DefaultThreadContextStore class', () => { - describe('get', () => { - it('should retrieve message metadata if context not already saved to instance', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as unknown as AllAssistantMiddlewareArgs; - const mockThreadContext = { channel_id: '123', thread_ts: '123', enterprise_id: null }; - const fakeClient = { - conversations: { - replies: sinon.fake.returns({ - messages: [{ - user: 'U12345', - ts: '12345', - metadata: { event_payload: mockThreadContext }, - }], - }), - }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - const threadContext = await mockContextStore.get(mockAssistantMiddlewareArgs); - - assert(fakeClient.conversations.replies.called); - assert.equal(threadContext, mockThreadContext); - }); - - it('should return an empty object if no message history exists', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as unknown as AllAssistantMiddlewareArgs; - const fakeClient = { conversations: { replies: sinon.fake.returns([]) } }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - const threadContext = await mockContextStore.get(mockAssistantMiddlewareArgs); - - assert.isEmpty(threadContext); - }); - - it('should return an empty object if no message metadata exists', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as unknown as AllAssistantMiddlewareArgs; - const fakeClient = { - conversations: { - replies: sinon.fake.returns({ - messages: [{ - user: 'U12345', - ts: '12345', - metadata: {}, - }], - }), - }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - const threadContext = await mockContextStore.get(mockAssistantMiddlewareArgs); - - assert.isEmpty(threadContext); - }); - - it('should retrieve instance context if it has been saved previously', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as any; - const fakeClient = { - conversations: { replies: sinon.fake.returns({ messages: [{ user: 'U12345', ts: '12345' }] }) }, - chat: { update: sinon.fake() }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - - await mockContextStore.save(mockAssistantMiddlewareArgs); - const threadContext = await mockContextStore.get(mockAssistantMiddlewareArgs); - - assert(fakeClient.conversations.replies.calledOnce); - assert.equal(threadContext, mockAssistantMiddlewareArgs.payload.assistant_thread.context); - }); - }); - - describe('save', () => { - it('should update instance context with threadContext', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as any; - const fakeClient = { - conversations: { replies: sinon.fake.returns({ messages: [] }) }, - chat: { update: sinon.fake() }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - - await mockContextStore.save(mockAssistantMiddlewareArgs); - const instanceContext = await mockContextStore.get(mockAssistantMiddlewareArgs); - - assert(fakeClient.conversations.replies.calledOnce); - assert.deepEqual(instanceContext, mockAssistantMiddlewareArgs.payload.assistant_thread.context); - }); - - it('should retrieve message history', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as any; - const fakeClient = { - conversations: { replies: sinon.fake.returns({}) }, - chat: { update: sinon.fake() }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - - await mockContextStore.save(mockAssistantMiddlewareArgs); - assert(fakeClient.conversations.replies.calledOnce); - }); - - it('should return early if no message history exists', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as any; - const fakeClient = { - conversations: { replies: sinon.fake.returns({}) }, - chat: { update: sinon.fake() }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - - await mockContextStore.save(mockAssistantMiddlewareArgs); - assert(fakeClient.chat.update.notCalled); - }); - - it('should update first bot message metadata with threadContext', async () => { - const mockContextStore = new DefaultThreadContextStore(); - const mockAssistantMiddlewareArgs = createMockAssistantMiddlewareArgs() as any; - const fakeClient = { - conversations: { replies: sinon.fake.returns({ messages: [{ user: 'U12345', ts: '12345', text: 'foo' }] }) }, - chat: { update: sinon.fake() }, - }; - mockAssistantMiddlewareArgs.client = fakeClient as unknown as WebClient; - const { channelId, context } = extractThreadInfo(mockAssistantMiddlewareArgs.payload); - const mockParams = { - channel: channelId, - ts: '12345', - text: 'foo', - metadata: { - event_type: 'assistant_thread_context', - event_payload: context, - }, - }; - - await mockContextStore.save(mockAssistantMiddlewareArgs); - assert(fakeClient.chat.update.calledWith(mockParams)); - }); - }); -}); - -function createMockAssistantMiddlewareArgs() { - return { - client: {}, - logger: { - debug: sinon.fake(), - }, - payload: { - type: 'assistant_thread_started', - assistant_thread: { - user_id: '', - context: { - channel_id: 'D01234567AR', - team_id: 'T123', - enterprise_id: 'E12345678', - }, - channel_id: 'D01234567AR', - thread_ts: '1234567890.123456', - }, - event_ts: '', - }, - context: { - botUserId: 'U12345', - }, - }; -} diff --git a/src/AssistantThreadContextStore.ts b/src/AssistantThreadContextStore.ts index 521408d11..2c99f14d3 100644 --- a/src/AssistantThreadContextStore.ts +++ b/src/AssistantThreadContextStore.ts @@ -1,19 +1,15 @@ -import { ChatUpdateArguments } from '@slack/web-api'; -import { Block, KnownBlock, MessageMetadataEventPayloadObject } from '@slack/types'; -import { AllAssistantMiddlewareArgs, extractThreadInfo } from './Assistant'; +import type { MessageMetadataEventPayloadObject } from '@slack/types'; +import type { Block, ChatUpdateArguments, KnownBlock } from '@slack/web-api'; +import { type AllAssistantMiddlewareArgs, extractThreadInfo } from './Assistant'; export interface AssistantThreadContextStore { get: GetThreadContextFn; save: SaveThreadContextFn; } -export interface GetThreadContextFn { - (args: AllAssistantMiddlewareArgs): Promise; -} +export type GetThreadContextFn = (args: AllAssistantMiddlewareArgs) => Promise; -export interface SaveThreadContextFn { - (args: AllAssistantMiddlewareArgs): Promise; -} +export type SaveThreadContextFn = (args: AllAssistantMiddlewareArgs) => Promise; export interface AssistantThreadContext { channel_id?: string; @@ -49,7 +45,7 @@ export class DefaultThreadContextStore implements AssistantThreadContextStore { // Find the first message in the thread that holds the current context using metadata. // See createSaveThreadContext below for a description and explanation for this approach. const initialMsg = thread.messages.find((m) => !('subtype' in m) && m.user === context.botUserId); - const threadContext = initialMsg && initialMsg.metadata ? initialMsg.metadata.event_payload : null; + const threadContext = initialMsg?.metadata ? initialMsg.metadata.event_payload : null; return threadContext || {}; } @@ -74,21 +70,18 @@ export class DefaultThreadContextStore implements AssistantThreadContextStore { // Find and update the initial Assistant message with the new context to ensure the // thread always contains the most recent context that user is sending messages from. const initialMsg = thread.messages.find((m) => !('subtype' in m) && m.user === context.botUserId); - if (initialMsg && initialMsg.ts) { + if (initialMsg?.ts) { const params: ChatUpdateArguments = { channel, ts: initialMsg.ts, text: initialMsg.text, + blocks: initialMsg.blocks ? (initialMsg.blocks as (Block | KnownBlock)[]) : [], metadata: { event_type: 'assistant_thread_context', event_payload: threadContext as MessageMetadataEventPayloadObject, }, }; - if (initialMsg.blocks) { - params.blocks = initialMsg.blocks as (KnownBlock | Block)[]; - } - await client.chat.update(params); } diff --git a/src/CustomFunction.ts b/src/CustomFunction.ts index 04ae1819d..b1d38779d 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -1,40 +1,32 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { FunctionExecutedEvent } from '@slack/types'; +import type { FunctionExecutedEvent } from '@slack/types'; import { + type FunctionsCompleteErrorResponse, + type FunctionsCompleteSuccessResponse, WebClient, - FunctionsCompleteErrorResponse, - FunctionsCompleteSuccessResponse, - WebClientOptions, + type WebClientOptions, } from '@slack/web-api'; import { - Middleware, - AllMiddlewareArgs, - AnyMiddlewareArgs, - SlackEventMiddlewareArgs, - Context, -} from './types'; + CustomFunctionCompleteFailError, + CustomFunctionCompleteSuccessError, + CustomFunctionInitializationError, +} from './errors'; import processMiddleware from './middleware/process'; -import { CustomFunctionCompleteFailError, CustomFunctionCompleteSuccessError, CustomFunctionInitializationError } from './errors'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, Context, Middleware, SlackEventMiddlewareArgs } from './types'; /** Interfaces */ interface FunctionCompleteArguments { - outputs?: { - [key: string]: any; - }; + // biome-ignore lint/suspicious/noExplicitAny: TODO: could probably improve custom function parameter shapes - deno-slack-sdk has a bunch of this stuff we should move to slack/types + outputs?: Record; } -export interface FunctionCompleteFn { - (params?: FunctionCompleteArguments): Promise; -} +export type FunctionCompleteFn = (params?: FunctionCompleteArguments) => Promise; interface FunctionFailArguments { error: string; } -export interface FunctionFailFn { - (params: FunctionFailArguments): Promise; -} +export type FunctionFailFn = (params: FunctionFailArguments) => Promise; export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { inputs: FunctionExecutedEvent['inputs']; @@ -50,8 +42,9 @@ type CustomFunctionExecuteMiddleware = Middleware[]; -export type AllCustomFunctionMiddlewareArgs - = T & AllMiddlewareArgs; +export type AllCustomFunctionMiddlewareArgs< + T extends SlackCustomFunctionMiddlewareArgs = SlackCustomFunctionMiddlewareArgs, +> = T & AllMiddlewareArgs; /** Constants */ @@ -67,11 +60,7 @@ export class CustomFunction { private middleware: CustomFunctionMiddleware; - public constructor( - callbackId: string, - middleware: CustomFunctionExecuteMiddleware, - clientOptions: WebClientOptions, - ) { + public constructor(callbackId: string, middleware: CustomFunctionExecuteMiddleware, clientOptions: WebClientOptions) { validate(callbackId, middleware); this.appWebClientOptions = clientOptions; @@ -80,7 +69,7 @@ export class CustomFunction { } public getMiddleware(): Middleware { - return async (args): Promise => { + return async (args): Promise => { if (isFunctionEvent(args) && this.matchesConstraints(args)) { return this.processEvent(args); } @@ -114,16 +103,17 @@ export class CustomFunction { throw new CustomFunctionCompleteSuccessError(errorMsg); } - return (params: Parameters[0] = {}) => client.functions.completeSuccess({ - token, - outputs: params.outputs || {}, - function_execution_id: functionExecutionId, - }); + return (params: Parameters[0] = {}) => + client.functions.completeSuccess({ + token, + outputs: params.outputs || {}, + function_execution_id: functionExecutionId, + }); } /** - * Factory for `fail()` utility - */ + * Factory for `fail()` utility + */ public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn { const token = selectToken(context); const { functionExecutionId } = context; @@ -161,12 +151,12 @@ export function validate(callbackId: string, middleware: CustomFunctionExecuteMi // Ensure array includes only functions if (Array.isArray(middleware)) { - middleware.forEach((fn) => { + for (const fn of middleware) { if (!(fn instanceof Function)) { const errorMsg = 'All CustomFunction middleware must be functions'; throw new CustomFunctionInitializationError(errorMsg); } - }); + } } } @@ -182,9 +172,8 @@ export async function processFunctionMiddleware( const lastCallback = callbacks.pop(); if (lastCallback !== undefined) { - await processMiddleware( - callbacks, args, context, client, logger, - async () => lastCallback({ ...args, context, client, logger }), + await processMiddleware(callbacks, args, context, client, logger, async () => + lastCallback({ ...args, context, client, logger }), ); } } @@ -205,7 +194,8 @@ function selectToken(context: Context): string | undefined { * 2. augments args with step lifecycle-specific properties/utilities * */ export function enrichFunctionArgs( - args: AllCustomFunctionMiddlewareArgs, webClientOptions: WebClientOptions, + args: AllCustomFunctionMiddlewareArgs, + webClientOptions: WebClientOptions, ): AllCustomFunctionMiddlewareArgs { const { next: _next, ...functionArgs } = args; const enrichedArgs = { ...functionArgs }; diff --git a/src/WorkflowStep.ts b/src/WorkflowStep.ts index fe08a79fe..84f941517 100644 --- a/src/WorkflowStep.ts +++ b/src/WorkflowStep.ts @@ -1,29 +1,31 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { WorkflowStepExecuteEvent } from '@slack/types'; -import { - KnownBlock, +import type { WorkflowStepExecuteEvent } from '@slack/types'; +import type { Block, + KnownBlock, ViewsOpenResponse, - WorkflowsUpdateStepResponse, WorkflowsStepCompletedResponse, WorkflowsStepFailedResponse, + WorkflowsUpdateStepResponse, } from '@slack/web-api'; -import { - Middleware, +import { WorkflowStepInitializationError } from './errors'; +import processMiddleware from './middleware/process'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, - SlackActionMiddlewareArgs, - SlackViewMiddlewareArgs, - WorkflowStepEdit, Context, + Middleware, + SlackActionMiddlewareArgs, SlackEventMiddlewareArgs, + SlackViewMiddlewareArgs, ViewWorkflowStepSubmitAction, + WorkflowStepEdit, } from './types'; -import processMiddleware from './middleware/process'; -import { WorkflowStepInitializationError } from './errors'; /** 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; @@ -31,16 +33,20 @@ export interface StepConfigureArguments { 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?: { - [key: string]: { + inputs?: Record< + string, + { + // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow inputs could be anything value: any; skip_variable_replacement?: boolean; - variables?: { - [key: string]: any; - }; - }; - }; + // biome-ignore lint/suspicious/noExplicitAny: user-defined workflow inputs could be anything + variables?: Record; + } + >; outputs?: { name: string; type: string; @@ -50,50 +56,71 @@ export interface StepUpdateArguments { 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 { - outputs?: { - [key: string]: any; - }; + // 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; }; } -export interface 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 StepConfigureFn = (params: StepConfigureArguments) => Promise; -export interface 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 StepUpdateFn = (params?: StepUpdateArguments) => Promise; -export interface 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 StepCompleteFn = (params?: StepCompleteArguments) => Promise; -export interface 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 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; @@ -102,20 +129,38 @@ export interface WorkflowStepExecuteMiddlewareArgs extends SlackEventMiddlewareA /** 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; @@ -123,8 +168,9 @@ export type AllWorkflowStepMiddlewareArgs { - return async (args): Promise => { + return async (args): Promise => { if (isStepEvent(args) && this.matchesConstraints(args)) { return this.processEvent(args); } @@ -185,6 +231,9 @@ export class WorkflowStep { /** 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') { @@ -201,11 +250,11 @@ export function validate(callbackId: string, config: WorkflowStepConfig): void { // Check for missing required keys const requiredKeys: (keyof WorkflowStepConfig)[] = ['save', 'edit', 'execute']; const missingKeys: (keyof WorkflowStepConfig)[] = []; - requiredKeys.forEach((key) => { + 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(', ')}`; @@ -214,16 +263,18 @@ export function validate(callbackId: string, config: WorkflowStepConfig): void { // Ensure a callback or an array of callbacks is present const requiredFns: (keyof WorkflowStepConfig)[] = ['save', 'edit', 'execute']; - requiredFns.forEach((fn) => { + 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( @@ -236,13 +287,15 @@ export async function processStepMiddleware( const lastCallback = callbacks.pop(); if (lastCallback !== undefined) { - await processMiddleware( - callbacks, args, context, client, logger, - async () => lastCallback({ ...args, context, client, logger }), + 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); } @@ -263,15 +316,16 @@ function createStepConfigure(args: AllWorkflowStepMiddlewareArgs[0]) => client.views.open({ - token, - trigger_id, - view: { - callback_id, - type: 'workflow_step', - ...params, - }, - }); + return (params: Parameters[0]) => + client.views.open({ + token, + trigger_id, + view: { + callback_id, + type: 'workflow_step', + ...params, + }, + }); } /** @@ -288,11 +342,12 @@ function createStepUpdate(args: AllWorkflowStepMiddlewareArgs[0] = {}) => client.workflows.updateStep({ - token, - workflow_step_edit_id, - ...params, - }); + return (params: Parameters[0] = {}) => + client.workflows.updateStep({ + token, + workflow_step_edit_id, + ...params, + }); } /** @@ -309,11 +364,12 @@ function createStepComplete(args: AllWorkflowStepMiddlewareArgs[0] = {}) => client.workflows.stepCompleted({ - token, - workflow_step_execute_id, - ...params, - }); + return (params: Parameters[0] = {}) => + client.workflows.stepCompleted({ + token, + workflow_step_execute_id, + ...params, + }); } /** @@ -345,10 +401,13 @@ function createStepFail(args: AllWorkflowStepMiddlewareArgs { // NOTE: expiresAt is in milliseconds set(conversationId: string, value: ConversationState, expiresAt?: number): Promise; @@ -17,6 +17,7 @@ export interface ConversationStore { * This should not be used in situations where there is more than once instance of the app running because state will * not be shared amongst the processes. */ +// biome-ignore lint/suspicious/noExplicitAny: user-defined convo values can be anything export class MemoryStore implements ConversationStore { private state: Map = new Map(); @@ -53,19 +54,20 @@ export class MemoryStore implements ConversationStore( store: ConversationStore, ): Middleware { return async ({ body, context, next, logger }) => { const { conversationId } = getTypeAndConversation(body); if (conversationId !== undefined) { - context.updateConversation = (conversation: ConversationState, - expiresAt?:number) => store.set(conversationId, conversation, expiresAt); + context.updateConversation = (conversation: ConversationState, expiresAt?: number) => + store.set(conversationId, conversation, expiresAt); try { context.conversation = await store.get(conversationId); logger.debug(`Conversation context loaded for ID: ${conversationId}`); } catch (error) { - const e = error as any; + const e = error as Error; if (e.message !== undefined && e.message !== 'Conversation not found') { // The conversation data can be expired - error: Conversation expired logger.debug(`Conversation context failed loading for ID: ${conversationId}, error: ${e.message}`); diff --git a/src/errors.ts b/src/errors.ts index a006b266e..582aea969 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,4 +1,4 @@ -import type { IncomingMessage, ServerResponse } from 'http'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import type { BufferedIncomingMessage } from './receivers/BufferedIncomingMessage'; export interface CodedError extends Error { @@ -10,7 +10,7 @@ export interface CodedError extends Error { res?: ServerResponse; // HTTPReceiverDeferredRequestError } -// eslint-disable-next-line @typescript-eslint/no-explicit-any +// biome-ignore lint/suspicious/noExplicitAny: errors can be anything export function isCodedError(err: any): err is CodedError { return 'code' in err; } @@ -42,6 +42,7 @@ export enum ErrorCode { */ UnknownError = 'slack_bolt_unknown_error', + // TODO: remove workflow step stuff in bolt v5 WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', CustomFunctionInitializationError = 'slack_bolt_custom_function_initialization_error', @@ -155,7 +156,10 @@ 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; } diff --git a/src/helpers.ts b/src/helpers.ts index 74889d698..394dc87a3 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -1,27 +1,26 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { - SlackEventMiddlewareArgs, +import type { + AnyMiddlewareArgs, + MessageShortcut, + OptionsSource, + ReceiverEvent, + SlackAction, + SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, + SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, - SlackActionMiddlewareArgs, SlackShortcutMiddlewareArgs, - SlackAction, - OptionsSource, - MessageShortcut, - AnyMiddlewareArgs, - ReceiverEvent, } from './types'; /** * Internal data type for capturing the class of event processed in App#onIncomingEvent() */ export enum IncomingEventType { - Event, - Action, - Command, - Options, - ViewAction, - Shortcut, + Event = 0, + Action = 1, + Command = 2, + Options = 3, + ViewAction = 4, // TODO: terminology: ViewAction? Why Action? + Shortcut = 5, } // ---------------------------- @@ -35,6 +34,7 @@ const eventTypesToSkipAuthorize = ['app_uninstalled', 'tokens_revoked']; * This is analogous to WhenEventHasChannelContext and the conditional type that checks SlackAction for a channel * context. */ +// biome-ignore lint/suspicious/noExplicitAny: response bodies can be anything export function getTypeAndConversation(body: any): { type?: IncomingEventType; conversationId?: string } { if (body.event !== undefined) { const { event } = body as SlackEventMiddlewareArgs['body']; @@ -59,7 +59,7 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType; c // Using non-null assertion (!) because the alternative is to use `foundConversation: (string | undefined)`, which // impedes the very useful type checker help above that ensures the value is only defined to strings, not // undefined. This is safe when used in combination with the || operator with a default value. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + // biome-ignore lint/style/noNonNullAssertion: TODO: revisit this and use the types return foundConversationId! || undefined; })(); @@ -81,6 +81,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') { const actionBody = body as SlackActionMiddlewareArgs['body']; return { diff --git a/src/index.ts b/src/index.ts index b03d566fa..935b180a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -21,16 +21,16 @@ export { default as AwsLambdaReceiver, AwsLambdaReceiverOptions } from './receiv export { BufferedIncomingMessage } from './receivers/BufferedIncomingMessage'; export { - HTTPModuleFunctions, RequestVerificationOptions, ReceiverDispatchErrorHandlerArgs, ReceiverProcessEventErrorHandlerArgs, ReceiverUnhandledRequestHandlerArgs, } from './receivers/HTTPModuleFunctions'; +export * as HTTPModuleFunctions from './receivers/HTTPModuleFunctions'; export { HTTPResponseAck } from './receivers/HTTPResponseAck'; export { - SocketModeFunctions, + defaultProcessEventErrorHandler, SocketModeReceiverProcessEventErrorHandlerArgs, } from './receivers/SocketModeFunctions'; @@ -73,4 +73,5 @@ export { InstallProviderOptions, } from '@slack/oauth'; -export * from '@slack/types'; +export * as types from '@slack/types'; +export * as webApi from '@slack/web-api'; diff --git a/src/middleware/builtin.spec.ts b/src/middleware/builtin.spec.ts deleted file mode 100644 index 0d8265f8d..000000000 --- a/src/middleware/builtin.spec.ts +++ /dev/null @@ -1,901 +0,0 @@ -import 'mocha'; -import { assert } from 'chai'; -import sinon from 'sinon'; -import rewiremock from 'rewiremock'; -import { Logger } from '@slack/logger'; -import { WebClient } from '@slack/web-api'; -import { - AppHomeOpenedEvent, - AppMentionEvent, - GenericMessageEvent, - MessageEvent, - SlackEvent, -} from '@slack/types'; -import { ErrorCode } from '../errors'; -import { Override, createFakeLogger } from '../test-helpers'; -import { - SlackEventMiddlewareArgs, - NextFn, - Context, - SlackCommandMiddlewareArgs, -} from '../types'; -import { onlyCommands, onlyEvents, matchCommandName, matchEventType, subtype } from './builtin'; -import { SlashCommand } from '../types/command'; - -// Test fixtures -const validCommandPayload: SlashCommand = { - token: 'token-value', - command: '/hi', - text: 'Steve!', - response_url: 'https://hooks.slack.com/foo/bar', - trigger_id: 'trigger-id-value', - user_id: 'U1234567', - user_name: 'steve', - team_id: 'T1234567', - team_domain: 'awesome-eng-team', - channel_id: 'C1234567', - channel_name: 'random', - api_app_id: 'A123456', -}; - -const appMentionEvent: AppMentionEvent = { - type: 'app_mention', - username: 'USERNAME', - user: 'U1234567', - text: 'this is my message', - ts: '123.123', - channel: 'C1234567', - event_ts: '123.123', - thread_ts: '123.123', -}; - -const appHomeOpenedEvent: AppHomeOpenedEvent = { - type: 'app_home_opened', - user: 'USERNAME', - channel: 'U1234567', - tab: 'home', - view: { - type: 'home', - blocks: [], - external_id: '', - }, - event_ts: '123.123', -}; - -const botMessageEvent: MessageEvent = { - type: 'message', - subtype: 'bot_message', - channel: 'CHANNEL_ID', - event_ts: '123.123', - user: 'U1234567', - ts: '123.123', - text: 'this is my message', - bot_id: 'B1234567', - channel_type: 'channel', -}; - -const noop = () => Promise.resolve(undefined); -const sayNoop = () => Promise.resolve({ ok: true }); - -describe('Built-in global middleware', () => { - describe('matchMessage()', () => { - function initializeTestCase(pattern: string | RegExp): Mocha.AsyncFunc { - return async () => { - // Arrange - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - - // Assert - assert.isOk(middleware); - }; - } - - function matchesPatternTestCase( - pattern: string | RegExp, - matchingText: string, - buildFakeEvent: (content: string) => SlackEvent, - ): Mocha.AsyncFunc { - return async () => { - // Arrange - const dummyContext: DummyContext = {}; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - event: buildFakeEvent(matchingText), - context: dummyContext, - } as unknown as MessageMiddlewareArgs; - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.called); - // The following assertion(s) check behavior that is only targeted at RegExp patterns - if (typeof pattern !== 'string') { - if (dummyContext.matches !== undefined) { - assert.lengthOf(dummyContext.matches, 1); - } else { - assert.fail(); - } - } - }; - } - - function notMatchesPatternTestCase( - pattern: string | RegExp, - nonMatchingText: string, - buildFakeEvent: (content: string) => SlackEvent, - ): Mocha.AsyncFunc { - return async () => { - // Arrange - const dummyContext = {}; - const fakeNext = sinon.fake(); - const fakeArgs = { - event: buildFakeEvent(nonMatchingText), - context: dummyContext, - next: fakeNext, - } as unknown as MessageMiddlewareArgs; - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - assert.notProperty(dummyContext, 'matches'); - }; - } - - function noTextMessageTestCase(pattern: string | RegExp): Mocha.AsyncFunc { - return async () => { - // Arrange - const dummyContext = {}; - const fakeNext = sinon.fake(); - const fakeArgs = { - event: createFakeMessageEvent([{ type: 'divider' }]), - context: dummyContext, - next: fakeNext, - } as unknown as MessageMiddlewareArgs; - const { matchMessage } = await importBuiltin(); - - // Act - const middleware = matchMessage(pattern); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - assert.notProperty(dummyContext, 'matches'); - }; - } - - describe('using a string pattern', () => { - const pattern = 'foo'; - const matchingText = 'foobar'; - const nonMatchingText = 'bar'; - it('should initialize', initializeTestCase(pattern)); - it( - 'should match message events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeMessageEvent), - ); - it( - 'should match app_mention events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeAppMentionEvent), - ); - it( - 'should filter out message events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeMessageEvent), - ); - it( - 'should filter out app_mention events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeAppMentionEvent), - ); - it('should filter out message events which do not have text (block kit)', noTextMessageTestCase(pattern)); - }); - - describe('using a RegExp pattern', () => { - const pattern = /foo/; - const matchingText = 'foobar'; - const nonMatchingText = 'bar'; - it('should initialize', initializeTestCase(pattern)); - it( - 'should match message events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeMessageEvent), - ); - it( - 'should match app_mention events with a pattern that matches', - matchesPatternTestCase(pattern, matchingText, createFakeAppMentionEvent), - ); - it( - 'should filter out message events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeMessageEvent), - ); - it( - 'should filter out app_mention events with a pattern that does not match', - notMatchesPatternTestCase(pattern, nonMatchingText, createFakeAppMentionEvent), - ); - it('should filter out message events which do not have text (block kit)', noTextMessageTestCase(pattern)); - }); - }); - - describe('directMention()', () => { - it('should bail when the context does not provide a bot user ID', async () => { - // Arrange - const fakeArgs = { - next: () => Promise.resolve(), - message: createFakeMessageEvent(), - context: { - isEnterpriseInstall: false, - }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - - let error; - - try { - await middleware(fakeArgs); - } catch (err) { - error = err; - } - - assert.instanceOf(error, Error); - assert.propertyVal(error, 'code', ErrorCode.ContextMissingPropertyError); - assert.propertyVal(error, 'missingProperty', 'botUserId'); - }); - - it('should match message events that mention the bot user ID at the beginning of message text', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = `<@${fakeBotUserId}> hi`; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.called); - }); - - it('should not match message events that do not mention the bot user ID', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = 'hi'; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should not match message events that mention the bot user ID NOT at the beginning of message text', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = `hello <@${fakeBotUserId}>`; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should not match message events which do not have text (block kit)', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent([{ type: 'divider' }]), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should not match message events that contain a link to a conversation at the beginning', async () => { - // Arrange - const fakeBotUserId = 'B123456'; - const messageText = '<#C12345> hi'; - const fakeNext = sinon.fake(); - const fakeArgs = { - next: fakeNext, - message: createFakeMessageEvent(messageText), - context: { botUserId: fakeBotUserId }, - } as unknown as MessageMiddlewareArgs; - const { directMention } = await importBuiltin(); - - // Act - const middleware = directMention(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - }); - - describe('ignoreSelf()', () => { - it("should immediately call next(), because incoming middleware args don't contain event", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - command: { - command: '/fakeCommand', - }, - } as unknown as CommandMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.calledOnce); - }); - - it('should look for an event identified as a bot message from the same bot ID as this app and skip it', async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - // TODO: Fix typings here - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: 'message', - subtype: 'bot_message', - bot_id: fakeBotUserId, - }, - message: { - type: 'message', - subtype: 'bot_message', - bot_id: fakeBotUserId, - }, - } as any; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it('should filter an event out when only a botUserId is passed', async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId }, - event: { - type: 'tokens_revoked', - user: fakeBotUserId, - }, - } as unknown as TokensRevokedMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it("should filter an event out, because it matches our own app and shouldn't be retained", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: 'tokens_revoked', - user: fakeBotUserId, - }, - } as unknown as TokensRevokedMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it("should filter an event out, because it matches our own app and shouldn't be retained", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const fakeArgs = { - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: 'tokens_revoked', - user: fakeBotUserId, - }, - } as unknown as TokensRevokedMiddlewareArgs; - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await middleware(fakeArgs); - - // Assert - assert(fakeNext.notCalled); - }); - - it("shouldn't filter an event out, because it should be retained", async () => { - // Arrange - const fakeNext = sinon.fake(); - const fakeBotUserId = 'BUSER1'; - const eventsWhichShouldNotBeFilteredOut = ['member_joined_channel', 'member_left_channel']; - - const listOfFakeArgs = eventsWhichShouldNotBeFilteredOut.map((eventType) => ({ - next: fakeNext, - context: { botUserId: fakeBotUserId, botId: fakeBotUserId }, - event: { - type: eventType, - user: fakeBotUserId, - }, - } as unknown as MemberJoinedOrLeftChannelMiddlewareArgs)); - - const { ignoreSelf: getIgnoreSelfMiddleware } = await importBuiltin(); - - // Act - const middleware = getIgnoreSelfMiddleware(); - await Promise.all(listOfFakeArgs.map(middleware)); - - // Assert - assert.equal(fakeNext.callCount, listOfFakeArgs.length); - }); - }); - - describe('onlyCommands', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - it('should detect valid requests', async () => { - const payload: SlashCommand = { ...validCommandPayload }; - const fakeNext = sinon.fake(); - const args = { - logger, - client, - payload, - command: payload, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - await onlyCommands(args); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const payload: any = {}; - const fakeNext = sinon.fake(); - const args = { - logger, - client, - payload, - action: payload, - command: undefined, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - await onlyCommands(args); - assert.isTrue(fakeNext.notCalled); - }); - }); - - describe('matchCommandName', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - function buildArgs(fakeNext: NextFn): SlackCommandMiddlewareArgs & MiddlewareCommonArgs { - const payload: SlashCommand = { ...validCommandPayload }; - return { - payload, - logger, - client, - command: payload, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - } - - it('should detect requests that match exactly', async () => { - const fakeNext = sinon.fake(); - await matchCommandName('/hi')(buildArgs(fakeNext)); - assert.isTrue(fakeNext.called); - }); - - it('should detect requests that match a pattern', async () => { - const fakeNext = sinon.fake(); - await matchCommandName(/h/)(buildArgs(fakeNext)); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const fakeNext = sinon.fake(); - await matchCommandName('/hello')(buildArgs(fakeNext)); - assert.isTrue(fakeNext.notCalled); - }); - }); - - describe('onlyEvents', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - it('should detect valid requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Removing type def here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const args /* : SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } */ = { - payload: appMentionEvent, - event: appMentionEvent, - message: null as never, // a bit hackey to satisfy TS compiler as 'null' cannot be assigned to type 'never' - body: { - token: 'token-value', - team_id: 'T1234567', - api_app_id: 'A1234567', - event: appMentionEvent, - type: 'event_callback', - event_id: 'event-id-value', - event_time: 123, - authed_users: [], - }, - say: sayNoop, - }; - const allArgs = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ...args, - }; - // FIXME: Using any is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - await onlyEvents(allArgs as any); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const payload: SlashCommand = { ...validCommandPayload }; - const fakeNext = sinon.fake(); - const args = { - logger, - client, - payload, - command: payload, - body: payload, - say: sayNoop, - respond: noop, - ack: noop, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - }; - await onlyEvents(args); - assert.isFalse(fakeNext.called); - }); - }); - - describe('matchEventType', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - function buildArgs(): SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } { - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - return { - payload: appMentionEvent, - event: appMentionEvent, - message: null as never, // a bit hackey to satisfy TS compiler as 'null' cannot be assigned to type 'never' - body: { - token: 'token-value', - team_id: 'T1234567', - api_app_id: 'A1234567', - event: appMentionEvent, - type: 'event_callback', - event_id: 'event-id-value', - event_time: 123, - authed_users: [], - }, - say: sayNoop, - } as any; - } - - function buildArgsAppHomeOpened(): SlackEventMiddlewareArgs<'app_home_opened'> & { - event?: SlackEvent; - } { - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - return { - payload: appHomeOpenedEvent, - event: appHomeOpenedEvent, - message: null as never, // a bit hackey to satisfy TS compiler as 'null' cannot be assigned to type 'never' - body: { - token: 'token-value', - team_id: 'T1234567', - api_app_id: 'A1234567', - event: appHomeOpenedEvent, - type: 'event_callback', - event_id: 'event-id-value', - event_time: 123, - authed_users: [], - }, - say: sayNoop, - } as any; - } - - it('should detect valid requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType('app_mention')(args); - assert.isTrue(fakeNext.called); - }); - - it('should detect valid RegExp requests with app_mention', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType(/app_mention|app_home_opened/)(args); - assert.isTrue(fakeNext.called); - }); - - it('should detect valid RegExp requests with app_home_opened', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgsAppHomeOpened() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType(/app_mention|app_home_opened/)(args); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await matchEventType('app_home_opened')(args); - assert.isFalse(fakeNext.called); - }); - - it('should skip other requests for RegExp', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - } as any; - await matchEventType(/foo/)(args); - assert.isFalse(fakeNext.called); - }); - }); - - describe('subtype', () => { - const logger = createFakeLogger(); - const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); - - function buildArgs(): SlackEventMiddlewareArgs<'message'> & { event?: SlackEvent } { - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - return { - payload: botMessageEvent, - event: botMessageEvent, - message: botMessageEvent, - body: { - token: 'token-value', - team_id: 'T1234567', - api_app_id: 'A1234567', - event: botMessageEvent, - type: 'event_callback', - event_id: 'event-id-value', - event_time: 123, - authed_users: [], - }, - say: sayNoop, - } as any; - } - - it('should detect valid requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await subtype('bot_message')(args); - assert.isTrue(fakeNext.called); - }); - - it('should skip other requests', async () => { - const fakeNext = sinon.fake(); - // FIXME: Using any here is a workaround for TypeScript 4.7 breaking changes - // TS2589: Type instantiation is excessively deep and possibly infinite. - const _args = buildArgs() as any; - const args = { - logger, - client, - next: fakeNext, - context: { - isEnterpriseInstall: false, - }, - ..._args, - }; - await subtype('me_message')(args); - assert.isFalse(fakeNext.called); - }); - }); -}); - -/* Testing Harness */ - -interface DummyContext { - matches?: RegExpExecArray; -} - -interface MiddlewareCommonArgs { - next: NextFn; - context: Context; - logger: Logger; - client: WebClient; -} -type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & MiddlewareCommonArgs; -type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> & MiddlewareCommonArgs; - -type MemberJoinedOrLeftChannelMiddlewareArgs = SlackEventMiddlewareArgs<'member_joined_channel' | 'member_left_channel'> & MiddlewareCommonArgs; - -type CommandMiddlewareArgs = SlackCommandMiddlewareArgs & MiddlewareCommonArgs; - -async function importBuiltin(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./builtin'), overrides); -} - -function createFakeMessageEvent(content: string | GenericMessageEvent['blocks'] = ''): MessageEvent { - const event: Partial = { - type: 'message', - channel: 'CHANNEL_ID', - user: 'USER_ID', - ts: 'MESSAGE_ID', - }; - if (typeof content === 'string') { - event.text = content; - } else { - event.blocks = content; - } - return event as MessageEvent; -} - -function createFakeAppMentionEvent(text: string = ''): AppMentionEvent { - const event: Partial = { - text, - type: 'app_mention', - user: 'USER_ID', - ts: 'MESSAGE_ID', - channel: 'CHANNEL_ID', - event_ts: 'MESSAGE_ID', - }; - return event as AppMentionEvent; -} diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index c6cf43e4a..90f00d307 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -1,120 +1,122 @@ -/* eslint-disable @typescript-eslint/dot-notation */ -import { SlackEvent } from '@slack/types'; -import { - Middleware, +import type { ActionConstraints, OptionsConstraints, ShortcutConstraints, ViewConstraints } from '../App'; +import { ContextMissingPropertyError } from '../errors'; +import type { AnyMiddlewareArgs, + BlockElementAction, + BlockSuggestion, + DialogSubmitAction, + DialogSuggestion, + EventTypePattern, + GlobalShortcut, + InteractiveMessage, + InteractiveMessageSuggestion, + MessageShortcut, + Middleware, SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, SlackShortcutMiddlewareArgs, - SlackViewMiddlewareArgs, - SlackAction, - SlackShortcut, - SlashCommand, - SlackOptions, - BlockSuggestion, - InteractiveMessageSuggestion, - DialogSuggestion, - InteractiveMessage, - DialogSubmitAction, - GlobalShortcut, - MessageShortcut, - BlockElementAction, SlackViewAction, - EventTypePattern, - ViewOutput, + SlackViewMiddlewareArgs, } from '../types'; -import { ActionConstraints, ViewConstraints, ShortcutConstraints, OptionsConstraints } from '../App'; -import { ContextMissingPropertyError } from '../errors'; + +/** Type predicate that can narrow payloads block action or suggestion payloads */ +function isBlockPayload( + payload: + | SlackActionMiddlewareArgs['payload'] + | SlackOptionsMiddlewareArgs['payload'] + | SlackViewMiddlewareArgs['payload'], +): payload is BlockElementAction | BlockSuggestion { + return 'action_id' in payload && payload.action_id !== undefined; +} + +type CallbackIdentifiedBody = + | InteractiveMessage + | DialogSubmitAction + | MessageShortcut + | GlobalShortcut + | InteractiveMessageSuggestion + | DialogSuggestion; + +// TODO: consider exporting these type guards for use elsewhere within bolt +// TODO: is there overlap with `function_executed` event here? +function isCallbackIdentifiedBody( + body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'] | SlackShortcutMiddlewareArgs['body'], +): body is CallbackIdentifiedBody { + return 'callback_id' in body && body.callback_id !== undefined; +} + +// TODO: clarify terminology used internally: event vs. body vs. payload +/** Type predicate that can narrow event bodies to ones containing Views */ +function isViewBody( + body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'] | SlackViewMiddlewareArgs['body'], +): body is SlackViewAction { + return 'view' in body && body.view !== undefined; +} + +function isEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewareArgs { + return 'event' in args && args.event !== undefined; +} + +function isMessageEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewareArgs<'message'> { + return isEventArgs(args) && 'message' in args; +} /** * Middleware that filters out any event that isn't an action */ -export const onlyActions: Middleware = async (args) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { action, next } = args as any; // FIXME: workaround for TypeScript 4.7 breaking changes - // Filter out any non-actions - if (action === undefined) { - return; +export const onlyActions: Middleware = async (args) => { + if ('action' in args && args.action) { + await args.next(); } - // It matches so we should continue down this middleware listener chain - await next(); }; /** * Middleware that filters out any event that isn't a shortcut */ -export const onlyShortcuts: Middleware = async (args) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { shortcut, next } = args as any; // FIXME: workaround for TypeScript 4.7 breaking changes - // Filter out any non-shortcuts - if (shortcut === undefined) { - return; +export const onlyShortcuts: Middleware = async (args) => { + if ('shortcut' in args && args.shortcut) { + await args.next(); } - - // It matches so we should continue down this middleware listener chain - await next(); }; /** * Middleware that filters out any event that isn't a command */ -export const onlyCommands: Middleware = async (args) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { command, next } = args as any; // FIXME: workaround for TypeScript 4.7 breaking changes - // Filter out any non-commands - if (command === undefined) { - return; +export const onlyCommands: Middleware = async (args) => { + if ('command' in args && args.command) { + await args.next(); } - - // It matches so we should continue down this middleware listener chain - await next(); }; /** * Middleware that filters out any event that isn't an options */ -export const onlyOptions: Middleware = async (args) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { options, next } = args as any; // FIXME: workaround for TypeScript 4.7 breaking changes - // Filter out any non-options requests - if (options === undefined) { - return; +export const onlyOptions: Middleware = async (args) => { + if ('options' in args && args.options) { + await args.next(); } - - // It matches so we should continue down this middleware listener chain - await next(); }; +// TODO: event terminology here "event that isn't an event" wat /** * Middleware that filters out any event that isn't an event */ -export const onlyEvents: Middleware = async (args) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { event, next } = args as any; // FIXME: workaround for TypeScript 4.7 breaking changes - // Filter out any non-events - if (event === undefined) { - return; +export const onlyEvents: Middleware = async (args) => { + if (isEventArgs(args)) { + await args.next(); } - - // It matches so we should continue down this middleware listener chain - await next(); }; +// TODO: event terminology "ViewAction" is confusing since "Action" we use for block actions /** * Middleware that filters out any event that isn't a view_submission or view_closed event */ -export const onlyViewActions: Middleware = async (args) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const { view, next } = args as any; // FIXME: workaround for TypeScript 4.7 breaking changes - // Filter out anything that doesn't have a view - if (view === undefined) { - return; +export const onlyViewActions: Middleware = async (args) => { + if ('view' in args) { + await args.next(); } - - // It matches so we should continue down this middleware listener chain - await next(); }; /** @@ -144,7 +146,7 @@ export function matchConstraints( tempMatches = payload.block_id.match(constraints.block_id); if (tempMatches !== null) { - context['blockIdMatches'] = tempMatches; + context.blockIdMatches = tempMatches; } else { return; } @@ -161,7 +163,7 @@ export function matchConstraints( tempMatches = payload.action_id.match(constraints.action_id); if (tempMatches !== null) { - context['actionIdMatches'] = tempMatches; + context.actionIdMatches = tempMatches; } else { return; } @@ -171,12 +173,12 @@ export function matchConstraints( // Check callback_id if ('callback_id' in constraints && constraints.callback_id !== undefined) { - let callbackId: string = ''; + let callbackId = ''; if (isViewBody(body)) { - callbackId = body['view']['callback_id']; + callbackId = body.view.callback_id; } else if (isCallbackIdentifiedBody(body)) { - callbackId = body['callback_id']; + callbackId = body.callback_id; } else { return; } @@ -189,7 +191,7 @@ export function matchConstraints( tempMatches = callbackId.match(constraints.callback_id); if (tempMatches !== null) { - context['callbackIdMatches'] = tempMatches; + context.callbackIdMatches = tempMatches; } else { return; } @@ -227,7 +229,7 @@ export function matchMessage( tempMatches = event.text.match(pattern); if (tempMatches !== null) { - context['matches'] = tempMatches; + context.matches = tempMatches; } else { return; } @@ -277,7 +279,7 @@ export function matchEventType(pattern: EventTypePattern): Middleware { - return async (args) => { - const botId = args.context.botId as string; - const botUserId = args.context.botUserId !== undefined ? (args.context.botUserId as string) : undefined; - - if (isEventArgs(args)) { - if (args.event.type === 'message') { - // Once we've narrowed the type down to SlackEventMiddlewareArgs, there's no way to further narrow it down to - // SlackEventMiddlewareArgs<'message'> without a cast, so the following couple lines do that. - // TODO: there must be a better way; generics-based types for event and middleware arguments likely the issue - // should instead use a discriminated union - const message = args.message as unknown as SlackEventMiddlewareArgs<'message'>['message']; - if (message !== undefined) { - // TODO: revisit this once we have all the message subtypes defined to see if we can do this better with - // type narrowing - // Look for an event that is identified as a bot message from the same bot ID as this app, and return to skip - if (message.subtype === 'bot_message' && message.bot_id === botId) { - return; - } - } +export const ignoreSelf: Middleware = async (args) => { + const { botId, botUserId } = args.context; + + if (isEventArgs(args)) { + if (isMessageEventArgs(args)) { + const { message } = args; + // Look for an event that is identified as a bot message from the same bot ID as this app, and return to skip + if (message.subtype === 'bot_message' && message.bot_id === botId) { + return; } + } - // Its an Events API event that isn't of type message, but the user ID might match our own app. Filter these out. - // However, some events still must be fired, because they can make sense. - const eventsWhichShouldBeKept = ['member_joined_channel', 'member_left_channel']; - const isEventShouldBeKept = eventsWhichShouldBeKept.includes(args.event.type); + // It's an Events API event that isn't of type message, but the user ID might match our own app. Filter these out. + // However, some events still must be fired, because they can make sense. + const eventsWhichShouldBeKept = ['member_joined_channel', 'member_left_channel']; - if (botUserId !== undefined && 'user' in args.event && args.event.user === botUserId && !isEventShouldBeKept) { - return; - } + if ( + botUserId !== undefined && + 'user' in args.event && + args.event.user === botUserId && + !eventsWhichShouldBeKept.includes(args.event.type) + ) { + return; } + } - // If all the previous checks didn't skip this message, then its okay to resume to next - await args.next(); - }; -} + // If all the previous checks didn't skip this message, then its okay to resume to next + await args.next(); +}; +// TODO: breaking change: constrain the subtype argument to be a valid message subtype /** * Filters out any message events whose subtype does not match the provided subtype. */ @@ -342,75 +336,36 @@ export function subtype(subtype1: string): Middleware[@#!])?(?[^>|]+)(?:\|(? - Add to Slack - - - `; -} - -// Deprecated: this function will be removed in the near future -// For backward-compatibility -export function renderHtmlForInstallPath(addToSlackUrl: string): string { - return defaultRenderHtmlForInstallPath(addToSlackUrl); -} diff --git a/src/receivers/verify-redirect-opts.ts b/src/receivers/verify-redirect-opts.ts index a659b9454..7ca6820bd 100644 --- a/src/receivers/verify-redirect-opts.ts +++ b/src/receivers/verify-redirect-opts.ts @@ -1,29 +1,29 @@ /** * Helper to verify redirect uri and redirect uri path exist and are consistent * when supplied. -*/ + */ import { AppInitializationError } from '../errors'; -import { HTTPReceiverOptions, HTTPReceiverInstallerOptions } from './HTTPReceiver'; +import type { HTTPReceiverInstallerOptions, HTTPReceiverOptions } from './HTTPReceiver'; export interface RedirectOptions { - redirectUri?: HTTPReceiverOptions['redirectUri'], - redirectUriPath?: HTTPReceiverInstallerOptions['redirectUriPath'], + redirectUri?: HTTPReceiverOptions['redirectUri']; + redirectUriPath?: HTTPReceiverInstallerOptions['redirectUriPath']; } export function verifyRedirectOpts({ redirectUri, redirectUriPath }: RedirectOptions): void { // if redirectUri is supplied, redirectUriPath is required - if ((redirectUri && !redirectUriPath)) { + if (redirectUri && !redirectUriPath) { throw new AppInitializationError( ' You have set a redirectUri but not a matching redirectUriPath.' + - ' Please provide this via installerOptions.redirectUriPath' + - ' Note: These should be consistent, e.g. https://example.com/redirect and /redirect', + ' Please provide this via installerOptions.redirectUriPath' + + ' Note: These should be consistent, e.g. https://example.com/redirect and /redirect', ); } // if both redirectUri and redirectUri are supplied, they must be consistent if (redirectUri && redirectUriPath && !redirectUri?.endsWith(redirectUriPath)) { throw new AppInitializationError( 'redirectUri and installerOptions.redirectUriPath should be consistent' + - ' e.g. https://example.com/redirect and /redirect', + ' e.g. https://example.com/redirect and /redirect', ); } } diff --git a/src/receivers/verify-request.ts b/src/receivers/verify-request.ts index 5b28bfa81..fcb4f8769 100644 --- a/src/receivers/verify-request.ts +++ b/src/receivers/verify-request.ts @@ -1,10 +1,6 @@ -// Deprecated: this function will be removed in the near future. Use HTTPModuleFunctions instead. -import { createHmac } from 'crypto'; -import type { IncomingMessage, ServerResponse } from 'http'; -import { ConsoleLogger, Logger } from '@slack/logger'; +import { createHmac } from 'node:crypto'; +import type { Logger } from '@slack/logger'; import tsscmp from 'tsscmp'; -import { BufferedIncomingMessage } from './BufferedIncomingMessage'; -import { HTTPModuleFunctions, RequestVerificationOptions } from './HTTPModuleFunctions'; // ------------------------------ // HTTP module independent methods @@ -16,8 +12,8 @@ export interface SlackRequestVerificationOptions { signingSecret: string; body: string; headers: { - 'x-slack-signature': string, - 'x-slack-request-timestamp': number, + 'x-slack-signature': string; + 'x-slack-request-timestamp': number; }; nowMilliseconds?: number; logger?: Logger; @@ -45,8 +41,11 @@ export function verifySlackRequest(options: SlackRequestVerificationOptions): vo // Rule 1: Check staleness if (requestTimestampSec < fiveMinutesAgoSec) { - throw new Error(`${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${requestTimestampMaxDeltaMin - } minutes or request is stale`); + throw new Error( + `${verifyErrorPrefix}: x-slack-request-timestamp must differ from system time by no more than ${ + requestTimestampMaxDeltaMin + } minutes or request is stale`, + ); } // Rule 2: Check signature @@ -80,27 +79,3 @@ export function isValidSlackRequest(options: SlackRequestVerificationOptions): b } return false; } - -// ------------------------------ -// legacy methods (deprecated) -// ------------------------------ - -const consoleLogger = new ConsoleLogger(); - -// Deprecated: this function will be removed in the near future. Use HTTPModuleFunctions instead. -export interface VerifyOptions extends RequestVerificationOptions { - enabled?: boolean; - signingSecret: string; - nowMs?: () => number; - logger?: Logger; -} - -// Deprecated: this function will be removed in the near future. Use HTTPModuleFunctions instead. -export async function verify( - options: VerifyOptions, - req: IncomingMessage, - res?: ServerResponse, -): Promise { - consoleLogger.warn('This method is deprecated. Use HTTPModuleFunctions.parseAndVerifyHTTPRequest(options, req, res) instead.'); - return HTTPModuleFunctions.parseAndVerifyHTTPRequest(options, req, res); -} diff --git a/src/test-helpers.ts b/src/test-helpers.ts deleted file mode 100644 index 6d10ce4ff..000000000 --- a/src/test-helpers.ts +++ /dev/null @@ -1,71 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// eslint-disable-next-line import/no-extraneous-dependencies -import sinon, { SinonSpy } from 'sinon'; -import { Logger } from '@slack/logger'; - -export interface Override { - [packageName: string]: { - [exportName: string]: any; - }; -} - -export function mergeOverrides(...overrides: Override[]): Override { - let currentOverrides: Override = {}; - overrides.forEach((override) => { - currentOverrides = mergeObjProperties(currentOverrides, override); - }); - return currentOverrides; -} - -function mergeObjProperties(first: Override, second: Override): Override { - const merged: Override = {}; - const props = Object.keys(first).concat(Object.keys(second)); - props.forEach((prop) => { - if (second[prop] === undefined && first[prop] !== undefined) { - merged[prop] = first[prop]; - } else if (first[prop] === undefined && second[prop] !== undefined) { - merged[prop] = second[prop]; - } else { - // second always overwrites the first - merged[prop] = { ...first[prop], ...second[prop] }; - } - }); - return merged; -} - -export interface FakeLogger extends Logger { - setLevel: SinonSpy, ReturnType>; - getLevel: SinonSpy, ReturnType>; - setName: SinonSpy, ReturnType>; - debug: SinonSpy, ReturnType>; - info: SinonSpy, ReturnType>; - warn: SinonSpy, ReturnType>; - error: SinonSpy, ReturnType>; -} - -export function createFakeLogger(): FakeLogger { - return { - // NOTE: the two casts are because of a TypeScript inconsistency with tuple types and any[]. all tuple types - // should be assignable to any[], but TypeScript doesn't think so. - // UPDATE (Nov 2019): - // src/test-helpers.ts:49:15 - error TS2352: Conversion of type 'SinonSpy' to type 'SinonSpy<[LogLevel], - // void>' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, - // convert the expression to 'unknown' first. - // Property '0' is missing in type 'any[]' but required in type '[LogLevel]'. - // 49 setLevel: sinon.fake() as SinonSpy, ReturnType>, - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - setLevel: sinon.fake() as unknown as SinonSpy, ReturnType>, - getLevel: sinon.fake() as unknown as SinonSpy, ReturnType>, - setName: sinon.fake() as unknown as SinonSpy, ReturnType>, - debug: sinon.fake(), - info: sinon.fake(), - warn: sinon.fake(), - error: sinon.fake(), - }; -} - -export function delay(ms: number = 0): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} diff --git a/src/types/actions/block-action.spec.ts b/src/types/actions/block-action.spec.ts deleted file mode 100644 index 9b05efbdc..000000000 --- a/src/types/actions/block-action.spec.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { assert } from 'chai'; -import { - BlockAction, - MultiStaticSelectAction, - MultiChannelsSelectAction, - MultiUsersSelectAction, - MultiConversationsSelectAction, -} from './block-action'; - -describe('Interactivity payload types', () => { - describe('block-action action types', () => { - it('should be compatible with block_actions payloads', () => { - const payload: BlockAction = { - type: 'block_actions', - user: { - id: 'W111', - username: 'seratch', - team_id: 'T111', - }, - api_app_id: 'A02', - token: 'Shh_its_a_seekrit', - container: { - type: 'message', - text: 'The contents of the original message where the action originated', - }, - trigger_id: '12466734323.1395872398', - team: { - id: 'T111', - domain: 'foo', - enterprise_id: 'E111', - enterprise_name: 'Acme Corp', - }, - enterprise: { - id: 'E111', - name: 'Acme Corp', - }, - is_enterprise_install: false, - response_url: 'https://www.postresponsestome.com/T123567/1509734234', - // as of April 2021, actions have only one element though - actions: [ - { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'multi_conversations_select-action', - selected_conversations: ['C111', 'C222'], - action_ts: '1618009079.687263', - }, - { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'multi_conversations_select-action', - selected_conversations: ['C111', 'C222'], - action_ts: '1618009079.687263', - }, - ], - }; - assert.equal(payload.actions.length, 2); - }); - it('should be compatible with multi_users_select payloads', () => { - const payload: MultiUsersSelectAction = { - type: 'multi_users_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_users: ['W111', 'W222'], - initial_users: ['W111', 'W222'], - }; - assert.equal(payload.selected_users.length, 2); - assert.equal(payload.initial_users?.length, 2); - }); - it('should be compatible with multi_conversations_select payloads', () => { - const payload: MultiConversationsSelectAction = { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_conversations: ['C111', 'C222'], - initial_conversations: ['C111', 'C222'], - }; - assert.equal(payload.selected_conversations.length, 2); - assert.equal(payload.initial_conversations?.length, 2); - }); - it('should be compatible with multi_channels_select payloads', () => { - const payload: MultiChannelsSelectAction = { - type: 'multi_channels_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_channels: ['C111', 'C222'], - initial_channels: ['C111', 'C222'], - }; - assert.equal(payload.selected_channels.length, 2); - assert.equal(payload.initial_channels?.length, 2); - }); - }); - - describe('block-action element types', () => { - it('should be compatible with multi_static_select payloads', () => { - const payload: MultiStaticSelectAction = { - type: 'multi_static_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_options: [ - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-0', - }, - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-1', - }, - ], - initial_options: [ - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-0', - }, - { - text: { - type: 'plain_text', - text: '*this is plain_text text*', - emoji: true, - }, - value: 'value-1', - }, - ], - }; - assert.equal(payload.selected_options.length, 2); - assert.equal(payload.initial_options?.length, 2); - }); - it('should be compatible with multi_users_select payloads', () => { - const payload: MultiUsersSelectAction = { - type: 'multi_users_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_users: ['W111', 'W222'], - initial_users: ['W111', 'W222'], - }; - assert.equal(payload.selected_users.length, 2); - assert.equal(payload.initial_users?.length, 2); - }); - it('should be compatible with multi_conversations_select payloads', () => { - const payload: MultiConversationsSelectAction = { - type: 'multi_conversations_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_conversations: ['C111', 'C222'], - initial_conversations: ['C111', 'C222'], - }; - assert.equal(payload.selected_conversations.length, 2); - assert.equal(payload.initial_conversations?.length, 2); - }); - it('should be compatible with multi_channels_select payloads', () => { - const payload: MultiChannelsSelectAction = { - type: 'multi_channels_select', - block_id: 'b', - action_id: 'a', - action_ts: '111', - selected_channels: ['C111', 'C222'], - initial_channels: ['C111', 'C222'], - }; - assert.equal(payload.selected_channels.length, 2); - assert.equal(payload.initial_channels?.length, 2); - }); - }); -}); diff --git a/src/types/actions/block-action.ts b/src/types/actions/block-action.ts index 8c1d2b581..6962e02e6 100644 --- a/src/types/actions/block-action.ts +++ b/src/types/actions/block-action.ts @@ -1,6 +1,6 @@ -import { PlainTextElement, Confirmation, Option, RichTextBlock } from '@slack/types'; -import { StringIndexed } from '../helpers'; -import { ViewOutput, ViewStateValue } from '../view'; +import type { Confirmation, Option, PlainTextElement, RichTextBlock } from '@slack/types'; +import type { StringIndexed } from '../utilities'; +import type { ViewOutput, ViewStateValue } from '../view'; /** * All known actions from in Slack's interactive elements @@ -248,12 +248,13 @@ export interface BlockAction { + type?: A['type']; + block_id?: A extends BlockAction ? string | RegExp : never; + action_id?: A extends BlockAction ? string | RegExp : never; + // TODO: callback ID doesn't apply to block actions, so the SlackAction generic above is too wide to apply here. + // biome-ignore lint/suspicious/noExplicitAny: TODO: for better type safety, we may want to revisit this + callback_id?: Extract extends any ? string | RegExp : never; +} + +// TODO: the words (terminology) that follow don't make much sense. What differentiates SlackAction, BlockAction, ElementAction and BasicElementAction? /** * Arguments which listeners and middleware receive to process an action from Slack's Block Kit interactive components, * message actions, dialogs, or legacy interactive messages. @@ -35,22 +47,31 @@ export type SlackAction = BlockAction | InteractiveMessage | DialogSubmitAction * `BlockAction` can be used to create a type for this parameter based on an element's action type. In * this case `ElementAction` must extend `BasicElementAction`. */ -export interface SlackActionMiddlewareArgs { +export type SlackActionMiddlewareArgs = { payload: Action extends BlockAction ? ElementAction : Action extends InteractiveMessage ? InteractiveAction : Action; - action: this['payload']; + // too bad we can't use `this['payload']` in a type (as opposed to interface) but the use of `& unknown` below is too useful + action: Action extends BlockAction + ? ElementAction + : Action extends InteractiveMessage + ? InteractiveAction + : Action; body: Action; - // all action types except dialog submission have a channel context - say: Action extends Exclude ? SayFn : undefined; respond: RespondFn; ack: ActionAckFn; + // TODO: can we conditionally apply these custom-function-specific properties only in certain situations? how can we get function-scoped interactivity events included in the generics? 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 + // TODO: not exactly true: a block action could occur from a view. should improve this. + { say: SayFn } + : unknown); /** * Type function which given an action `A` returns a corresponding type for the `ack()` function. The function is used diff --git a/src/types/actions/interactive-message.spec.ts b/src/types/actions/interactive-message.spec.ts deleted file mode 100644 index 469cd4301..000000000 --- a/src/types/actions/interactive-message.spec.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { assert } from 'chai'; -import { InteractiveMessage, ButtonClick } from './interactive-message'; - -describe('Message shortcut payload types', () => { - it('should be compatible with block_actions payloads', () => { - const payload: InteractiveMessage = { - type: 'interactive_message', - actions: [ - { - name: 'foo', - type: 'button', - value: 'bar', - }, - { - name: 'foo', - type: 'button', - value: 'bar', - }, - ], - callback_id: 'id', - enterprise: { - id: 'E111', - name: 'test-org', - }, - team: { - id: 'T111', - domain: 'team-domain', - enterprise_id: 'E111', - enterprise_name: 'test-org', - }, - channel: { - id: 'C111', - name: 'random', - }, - user: { - id: 'W111', - name: 'seratch', - team_id: 'T111', - }, - action_ts: '111.222', - message_ts: '222.333', - attachment_id: 'XXX', - token: 'verificationt-oken', - is_app_unfurl: false, - original_message: {}, - response_url: 'https://hooks.slack.com/xxx', - trigger_id: '1111111', - is_enterprise_install: false, - }; - assert.equal(payload.actions.length, 2); - }); -}); diff --git a/src/types/actions/workflow-step-edit.ts b/src/types/actions/workflow-step-edit.ts index 85b7033fb..aa3c37bcd 100644 --- a/src/types/actions/workflow-step-edit.ts +++ b/src/types/actions/workflow-step-edit.ts @@ -2,6 +2,8 @@ * 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'; @@ -27,12 +29,13 @@ export interface WorkflowStepEdit { workflow_step: { workflow_id: string; step_id: string; - inputs: { - [key: string]: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputs: Record< + string, + { + // biome-ignore lint/suspicious/noExplicitAny: input parameters can accept anything value: any; - }; - }; + } + >; outputs: { name: string; type: string; diff --git a/src/types/command/index.ts b/src/types/command/index.ts index ebd35b4c6..9378bb390 100644 --- a/src/types/command/index.ts +++ b/src/types/command/index.ts @@ -1,5 +1,4 @@ -import { StringIndexed } from '../helpers'; -import { SayFn, RespondFn, RespondArguments, AckFn } from '../utilities'; +import type { AckFn, RespondArguments, RespondFn, SayFn, StringIndexed } from '../utilities'; /** * Arguments which listeners and middleware receive to process a slash command from Slack. diff --git a/src/types/events/index.ts b/src/types/events/index.ts index 82d40c644..7b485e5fc 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -1,24 +1,25 @@ -import { SlackEvent } from '@slack/types'; -import { StringIndexed } from '../helpers'; -import { SayFn } from '../utilities'; - -// TODO: for backwards compatibility; remove at next major (breaking change) -export * from '@slack/types'; +import type { SlackEvent } from '@slack/types'; +import type { SayFn, StringIndexed } from '../utilities'; /** * Arguments which listeners and middleware receive to process an event from Slack's Events API. */ -export interface SlackEventMiddlewareArgs { +export type SlackEventMiddlewareArgs = { payload: EventFromType; - event: this['payload']; - message: EventType extends 'message' ? this['payload'] : undefined; - body: EnvelopedEvent; - say: WhenEventHasChannelContext; - // Add `ack` as undefined for global middleware in TypeScript + event: EventFromType; + body: EnvelopedEvent>; + // Add `ack` as undefined for global middleware in TypeScript TODO: but why? spend some time digging into this ack?: undefined; -} +} & (EventType extends 'message' + ? // If this is a message event, add a `message` property + { message: EventFromType } + : unknown) & + (EventFromType extends { channel: string } | { item: { channel: string } } + ? // If this event contains a channel, add a `say` utility function + { say: SayFn } + : unknown); -interface BaseSlackEvent { +export interface BaseSlackEvent { type: T; } export type EventTypePattern = string | RegExp; @@ -38,9 +39,6 @@ export interface EnvelopedEvent extends StringIndexed { type: 'event_callback'; event_id: string; event_time: number; - // TODO: the two properties below are being deprecated on Feb 24, 2021 - authed_users?: string[]; - authed_teams?: string[]; is_ext_shared_channel?: boolean; authorizations?: Authorization[]; } @@ -59,15 +57,12 @@ interface Authorization { * When the string matches known event(s) from the `SlackEvent` union, only those types are returned (also as a union). * Otherwise, the `BasicSlackEvent` type is returned. */ -export type EventFromType = KnownEventFromType extends never ? - BaseSlackEvent : - KnownEventFromType; +export type EventFromType = KnownEventFromType extends never + ? BaseSlackEvent + : KnownEventFromType; export type KnownEventFromType = Extract; /** * Type function which tests whether or not the given `Event` contains a channel ID context for where the event * occurred, and returns `Type` when the test passes. Otherwise this returns `undefined`. */ -type WhenEventHasChannelContext = Event extends { channel: string } | { item: { channel: string } } - ? Type - : undefined; diff --git a/src/types/helpers.ts b/src/types/helpers.ts deleted file mode 100644 index 124f8423b..000000000 --- a/src/types/helpers.ts +++ /dev/null @@ -1,18 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -// TODO: breaking change: remove, unnecessary abstraction, just use Record directly -/** - * Extend this interface to build a type that is treated as an open set of properties, where each key is a string. - */ -export type StringIndexed = Record; - -// TODO: breaking change: no longer used! remove -/** - * @deprecated No longer works in TypeScript 4.3 - */ -export type KnownKeys<_T> = never; - -/** - * Type function which allows either types `T` or `U`, but not both. - */ -export type XOR = T | U extends Record ? (Without & U) | (Without & T) : T | U; -type Without = { [P in Exclude]?: never }; diff --git a/src/types/middleware.ts b/src/types/middleware.ts index d1d8878df..8f2bec71f 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -1,12 +1,12 @@ -import { WebClient } from '@slack/web-api'; -import { Logger } from '@slack/logger'; -import { StringIndexed } from './helpers'; -import { FunctionInputs, SlackEventMiddlewareArgs } from './events'; -import { SlackActionMiddlewareArgs } from './actions'; -import { SlackCommandMiddlewareArgs } from './command'; -import { SlackOptionsMiddlewareArgs } from './options'; -import { SlackShortcutMiddlewareArgs } from './shortcuts'; -import { SlackViewMiddlewareArgs } from './view'; +import type { Logger } from '@slack/logger'; +import type { WebClient } from '@slack/web-api'; +import type { SlackActionMiddlewareArgs } from './actions'; +import type { SlackCommandMiddlewareArgs } from './command'; +import type { FunctionInputs, SlackEventMiddlewareArgs } from './events'; +import type { SlackOptionsMiddlewareArgs } from './options'; +import type { SlackShortcutMiddlewareArgs } from './shortcuts'; +import type { StringIndexed } from './utilities'; +import type { SlackViewMiddlewareArgs } from './view'; // TODO: rename this to AnyListenerArgs, and all the constituent types export type AnyMiddlewareArgs = @@ -26,9 +26,9 @@ export interface AllMiddlewareArgs { // NOTE: Args should extend AnyMiddlewareArgs, but because of contravariance for function types, including that as a // constraint would mess up the interface of App#event(), App#message(), etc. -export interface Middleware { - (args: Args & AllMiddlewareArgs): Promise; -} +export type Middleware = ( + args: Args & AllMiddlewareArgs, +) => Promise; /** * Context object, which provides contextual information associated with an incoming requests. @@ -71,7 +71,7 @@ export interface Context extends StringIndexed { /** * Is the app installed at an Enterprise level? */ - isEnterpriseInstall: boolean, + isEnterpriseInstall: boolean; /** * A JIT and function-specific token that, when used to make API calls, diff --git a/src/types/options/index.spec.ts b/src/types/options/index.spec.ts deleted file mode 100644 index affd6d2fb..000000000 --- a/src/types/options/index.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { assert } from 'chai'; -import { BlockSuggestion, DialogSuggestion, InteractiveMessageSuggestion } from './index'; - -describe('External data source options event types', () => { - it('should be compatible with block_suggestion payloads', () => { - const payload: BlockSuggestion = { - type: 'block_suggestion', - user: { - id: 'W111', - name: 'primary-owner', - team_id: 'T111', - }, - container: { type: 'view', view_id: 'V111' }, - api_app_id: 'A111', - token: 'verification_token', - block_id: 'block-id-value', - action_id: 'action-id-value', - value: 'search word', - team: { - id: 'T111', - domain: 'workspace-domain', - enterprise_id: 'E111', - enterprise_name: 'Sandbox Org', - }, - view: { - id: 'V111', - team_id: 'T111', - type: 'modal', - blocks: [ - { - type: 'input', - block_id: '5ar+', - label: { type: 'plain_text', text: 'Label' }, - optional: false, - element: { type: 'plain_text_input', action_id: 'i5IpR' }, - }, - { - type: 'input', - block_id: 'block-id-value', - label: { type: 'plain_text', text: 'Search' }, - optional: false, - element: { - type: 'external_select', - action_id: 'action-id-value', - placeholder: { type: 'plain_text', text: 'Select an item' }, - }, - }, - { - type: 'input', - block_id: 'xxx', - label: { type: 'plain_text', text: 'Search (multi)' }, - optional: false, - element: { - type: 'multi_external_select', - action_id: 'yyy', - placeholder: { type: 'plain_text', text: 'Select an item' }, - }, - }, - ], - private_metadata: '', - callback_id: 'view-id', - state: { values: {} }, - hash: '111.xxx', - title: { type: 'plain_text', text: 'My App' }, - clear_on_close: false, - notify_on_close: false, - close: { type: 'plain_text', text: 'Cancel' }, - submit: { type: 'plain_text', text: 'Submit' }, - root_view_id: 'V111', - previous_view_id: null, - app_id: 'A111', - external_id: '', - app_installed_team_id: 'T111', - bot_id: 'B111', - }, - }; - assert.equal(payload.action_id, 'action-id-value'); - assert.equal(payload.value, 'search word'); - }); - - it('should be compatible with interactive_message payloads', () => { - const payload: InteractiveMessageSuggestion = { - name: 'bugs_list', - value: 'bot', - callback_id: 'select_remote_1234', - type: 'interactive_message', - team: { - id: 'T012AB0A1', - domain: 'pocket-calculator', - }, - channel: { - id: 'C012AB3CD', - name: 'general', - }, - user: { - id: 'U012A1BCJ', - name: 'bugcatcher', - }, - action_ts: '1481670445.010908', - message_ts: '1481670439.000007', - attachment_id: '1', - token: 'verification_token_string', - }; - assert.equal(payload.callback_id, 'select_remote_1234'); - assert.equal(payload.value, 'bot'); - }); - - it('should be compatible with dialog_suggestion payloads', () => { - const payload: DialogSuggestion = { - type: 'dialog_suggestion', - token: 'verification_token', - action_ts: '1596603332.676855', - team: { - id: 'T111', - domain: 'workspace-domain', - enterprise_id: 'E111', - enterprise_name: 'Sandbox Org', - }, - user: { id: 'W111', name: 'primary-owner', team_id: 'T111' }, - channel: { id: 'C111', name: 'test-channel' }, - name: 'types', - value: 'search keyword', - callback_id: 'dialog-callback-id', - state: 'Limo', - }; - assert.equal(payload.callback_id, 'dialog-callback-id'); - assert.equal(payload.value, 'search keyword'); - }); -}); diff --git a/src/types/options/index.ts b/src/types/options/index.ts index 88813dbf3..5522f9a4c 100644 --- a/src/types/options/index.ts +++ b/src/types/options/index.ts @@ -1,7 +1,6 @@ -import { Option, PlainTextElement } from '@slack/types'; -import { StringIndexed, XOR } from '../helpers'; -import { AckFn } from '../utilities'; -import { ViewOutput } from '../view/index'; +import type { Option, PlainTextElement } from '@slack/types'; +import type { AckFn, StringIndexed, XOR } from '../utilities'; +import type { ViewOutput } from '../view/index'; /** * Arguments which listeners and middleware receive to process an options request from Slack @@ -13,22 +12,32 @@ export interface SlackOptionsMiddlewareArgs; } +export type SlackOptions = BlockSuggestion | InteractiveMessageSuggestion | DialogSuggestion; + +// TODO: more strict typing to allow block/action_id for block_suggestion - not all of these properties apply to all of the members of the SlackOptions union +export interface OptionsConstraints { + type?: A['type']; + block_id?: A extends SlackOptions ? string | RegExp : never; + action_id?: A extends SlackOptions ? string | RegExp : never; + // biome-ignore lint/suspicious/noExplicitAny: TODO: for better type safety, we may want to revisit this + callback_id?: Extract extends any ? string | RegExp : never; +} + +// TODO: why call this 'source'? shouldn't it be Type, since it is just the type value? /** * All sources from which Slack sends options requests. */ -export type OptionsSource = 'interactive_message' | 'dialog_suggestion' | 'block_suggestion'; - -export type SlackOptions = BlockSuggestion | InteractiveMessageSuggestion | DialogSuggestion; +export type OptionsSource = SlackOptions['type']; +// TODO: the following three utility typies could be DRYed up w/ the similar KnownEventFromType utility used in events types export interface BasicOptionsPayload { type: Type; value: string; } - +// TODO: Is this useful? Events have something similar export type OptionsPayloadFromType = KnownOptionsPayloadFromType extends never ? BasicOptionsPayload : KnownOptionsPayloadFromType; - export type KnownOptionsPayloadFromType = Extract; /** @@ -148,6 +157,7 @@ type OptionsAckFn = Source extends 'block_suggesti ? AckFn>> : AckFn>>; +// TODO: why are the next two interfaces identical? export interface BlockOptions { options: Option[]; } @@ -162,7 +172,7 @@ export interface DialogOptions { } export interface OptionGroups { option_groups: ({ - label: PlainTextElement + label: PlainTextElement; } & Options)[]; } export interface DialogOptionGroups { @@ -170,57 +180,3 @@ export interface DialogOptionGroups { label: string; } & Options)[]; } - -// Don't delete the following interface for backward-compatibility -// We may remove it in v4 or newer - -/** - * A request for options for a select menu with an external data source, wrapped in the standard metadata. The menu - * can have a source of Slack's Block Kit external select elements, dialogs, or legacy interactive components. - * - * This describes the entire JSON-encoded body of a request. - * @deprecated You can use more specific types such as BlockSuggestionPayload - */ -export interface OptionsRequest extends StringIndexed { - value: string; - type: Source; - team: { - id: string; - domain: string; - enterprise_id?: string; // undocumented - enterprise_name?: string; // undocumented - } | null; - channel?: { - id: string; - name: string; - }; - user: { - id: string; - name: string; - team_id?: string; // undocumented - }; - token: string; - - name: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never; - callback_id: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never; - action_ts: Source extends 'interactive_message' | 'dialog_suggestion' ? string : never; - - message_ts: Source extends 'interactive_message' ? string : never; - attachment_id: Source extends 'interactive_message' ? string : never; - - api_app_id: Source extends 'block_suggestion' ? string : never; - action_id: Source extends 'block_suggestion' ? string : never; - block_id: Source extends 'block_suggestion' ? string : never; - container: Source extends 'block_suggestion' ? StringIndexed : never; - - // this appears in the block_suggestions schema, but we're not sure when its present or what its type would be - // eslint-disable-next-line @typescript-eslint/no-explicit-any - app_unfurl?: any; - - // exists for enterprise installs - is_enterprise_install?: boolean; - enterprise?: { - id: string; - name: string; - }; -} diff --git a/src/types/receiver.ts b/src/types/receiver.ts index b790f3e24..3edf509e2 100644 --- a/src/types/receiver.ts +++ b/src/types/receiver.ts @@ -1,7 +1,6 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import App from '../App'; -import { AckFn } from './index'; -import { StringIndexed } from './helpers'; +import type App from '../App'; +import type { AckFn } from './index'; +import type { StringIndexed } from './utilities'; export interface ReceiverEvent { // Parsed HTTP request body / Socket Mode message body @@ -20,12 +19,14 @@ export interface ReceiverEvent { // The function to acknowledge incoming requests // The details of implementation is encapsulated in a receiver - // TODO: Make the argument type more specific + // biome-ignore lint/suspicious/noExplicitAny: TODO: Make the argument type more specific ack: AckFn; } export interface Receiver { init(app: App): void; + // biome-ignore lint/suspicious/noExplicitAny: different receivers may have different types of arguments start(...args: any[]): Promise; + // biome-ignore lint/suspicious/noExplicitAny: different receivers may have different types of arguments stop(...args: any[]): Promise; } diff --git a/src/types/shortcuts/global-shortcut.ts b/src/types/shortcuts/global-shortcut.ts index 93c4fd9f9..101caf91e 100644 --- a/src/types/shortcuts/global-shortcut.ts +++ b/src/types/shortcuts/global-shortcut.ts @@ -3,6 +3,7 @@ * * This describes the entire JSON-encoded body of a request from Slack global shortcuts. */ +// TODO: move this to slack/types export interface GlobalShortcut { type: 'shortcut'; callback_id: string; diff --git a/src/types/shortcuts/index.ts b/src/types/shortcuts/index.ts index 7f42f8e0b..f609faacb 100644 --- a/src/types/shortcuts/index.ts +++ b/src/types/shortcuts/index.ts @@ -1,6 +1,6 @@ -import { MessageShortcut } from './message-shortcut'; -import { GlobalShortcut } from './global-shortcut'; -import { SayFn, RespondFn, AckFn } from '../utilities'; +import type { AckFn, RespondFn, SayFn } from '../utilities'; +import type { GlobalShortcut } from './global-shortcut'; +import type { MessageShortcut } from './message-shortcut'; // export * from './message-action'; export * from './global-shortcut'; @@ -11,16 +11,20 @@ export * from './message-shortcut'; */ export type SlackShortcut = GlobalShortcut | MessageShortcut; +export interface ShortcutConstraints { + type?: S['type']; + callback_id?: string | RegExp; +} + /** * Arguments which listeners and middleware receive to process a shortcut from Slack. * * The type parameter `Shortcut` represents the entire JSON-encoded request body from Slack. */ -export interface SlackShortcutMiddlewareArgs { +export type SlackShortcutMiddlewareArgs = { payload: Shortcut; - shortcut: this['payload']; - body: this['payload']; - say: Shortcut extends MessageShortcut ? SayFn : undefined; + shortcut: Shortcut; + body: Shortcut; respond: RespondFn; ack: AckFn; -} +} & (Shortcut extends MessageShortcut ? { say: SayFn } : unknown); diff --git a/src/types/shortcuts/message-shortcut.ts b/src/types/shortcuts/message-shortcut.ts index e40cf6e7c..484dd6fac 100644 --- a/src/types/shortcuts/message-shortcut.ts +++ b/src/types/shortcuts/message-shortcut.ts @@ -3,6 +3,7 @@ * * This describes the entire JSON-encoded body of a request from Slack message actions. */ +// TODO: move this to slack/types export interface MessageShortcut { type: 'message_action'; callback_id: string; @@ -15,7 +16,7 @@ export interface MessageShortcut { user?: string; // undocumented that this is optional, it won't be there for bot messages ts: string; text?: string; // undocumented that this is optional, but how could it exist on block kit based messages? - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: TODO: should try to type this more specifically for messages, maybe? [key: string]: any; }; user: { diff --git a/src/types/utilities.spec.ts b/src/types/utilities.spec.ts deleted file mode 100644 index 7c334b093..000000000 --- a/src/types/utilities.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { assert } from 'chai'; -import { RespondArguments } from './utilities'; - -describe('RespondArguments', () => { - it('has expected properties', () => { - const args: RespondArguments = { - response_type: 'in_channel', - text: 'Hey!', - // Verifying this parameter compiles - // See https://github.com/slackapi/bolt-python/pull/844 for the context - thread_ts: '111.222', - }; - assert.exists(args); - }); - it('has metadata', () => { - const args: RespondArguments = { - response_type: 'in_channel', - text: 'Hey!', - metadata: { - event_type: 'test-event', - event_payload: { foo: 'bar' }, - }, - }; - assert.exists(args); - }); -}); diff --git a/src/types/utilities.ts b/src/types/utilities.ts index 03d85a628..c70f5e692 100644 --- a/src/types/utilities.ts +++ b/src/types/utilities.ts @@ -1,38 +1,37 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ChatPostMessageArguments, ChatPostMessageResponse } from '@slack/web-api'; - -// (issue#951) KnownKeys no longer works in TypeScript 4.3 -type ChatPostMessageArgumentsKnownKeys = - | 'token' - | 'channel' - | 'text' - | 'as_user' - | 'attachments' - | 'blocks' - | 'metadata' - | 'icon_emoji' - | 'icon_url' - | 'link_names' - | 'mrkdwn' - | 'parse' - | 'reply_broadcast' - | 'thread_ts' - | 'unfurl_links' - | 'unfurl_media' - | 'username'; +import type { ChatPostMessageArguments, ChatPostMessageResponse } from '@slack/web-api'; +// TODO: breaking change: remove, unnecessary abstraction, just use Record directly +/** + * Extend this interface to build a type that is treated as an open set of properties, where each key is a string. + */ +// biome-ignore lint/suspicious/noExplicitAny: we're being quite explicit here +export type StringIndexed = Record; + +// TODO: unclear if this is helpful or just complicates further +/** + * Type function which allows either types `T` or `U`, but not both. + */ +export type XOR = T | U extends Record ? (Without & U) | (Without & T) : T | U; + +type Without = { [P in Exclude]?: never }; + +/** Type predicate for use with `Promise.allSettled` for filtering for resolved results. */ +export const isFulfilled = (p: PromiseSettledResult): p is PromiseFulfilledResult => p.status === 'fulfilled'; +/** Type predicate for use with `Promise.allSettled` for filtering for rejected results. */ +export const isRejected = (p: PromiseSettledResult): p is PromiseRejectedResult => p.status === 'rejected'; + +/** Using type parameter T (generic), can distribute the Omit over a union set. */ +// biome-ignore lint/suspicious/noExplicitAny: any is the opposite of never +type DistributiveOmit = T extends any ? Omit : never; // The say() utility function binds the message to the same channel as the incoming message that triggered the // listener. Therefore, specifying the `channel` argument is not required. -export type SayArguments = Pick> & { +export type SayArguments = DistributiveOmit & { + // TODO: This will be overwritten in the `createSay` factory method in App.ts anyways, so why include it? channel?: string; }; +export type SayFn = (message: string | SayArguments) => Promise; -export interface SayFn { - (message: string | SayArguments): Promise; -} - -export type RespondArguments = Pick -> & { +export type RespondArguments = DistributiveOmit & { /** Response URLs can be used to send ephemeral messages or in-channel messages using this argument */ response_type?: 'in_channel' | 'ephemeral'; replace_original?: boolean; @@ -40,10 +39,7 @@ export type RespondArguments = Pick; -} +// biome-ignore lint/suspicious/noExplicitAny: TODO: check if we can type this more strictly than any +export type RespondFn = (message: string | RespondArguments) => Promise; -export interface AckFn { - (response?: Response): Promise; -} +export type AckFn = (response?: Response) => Promise; diff --git a/src/types/view/index.ts b/src/types/view/index.ts index 24647b54d..0ca0ec1c7 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -1,15 +1,22 @@ -import { Block, KnownBlock, PlainTextElement, RichTextBlock, View } from '@slack/types'; -import { AckFn, RespondFn } from '../utilities'; +import type { Block, KnownBlock, PlainTextElement, RichTextBlock, View } from '@slack/types'; +import type { AckFn, RespondFn } from '../utilities'; +// TODO: terminology. 'action' does not belong here. /** * Known view action types */ export type SlackViewAction = | ViewSubmitAction | ViewClosedAction - | ViewWorkflowStepSubmitAction + | ViewWorkflowStepSubmitAction // TODO: remove workflow step stuff in bolt v5 | ViewWorkflowStepClosedAction; // +// TODO: add a type parameter here, just like the other constraint interfaces have. +export interface ViewConstraints { + callback_id?: string | RegExp; + type?: 'view_closed' | 'view_submission'; +} + /** * Arguments which listeners and middleware receive to process a view submission event from Slack. */ @@ -21,6 +28,7 @@ export interface SlackViewMiddlewareArgs { + await Promise.resolve(action); + }), +); + +app.action({ type: 'block_actions' }, async ({ action, say }) => { + expectType(action); + expectType(say); +}); + +app.action({ type: 'interactive_message' }, async ({ action, say }) => { + expectType(action); + expectType(say); +}); + +app.action({ type: 'dialog_submission' }, async ({ action }) => { + expectType(action); +}); + +expectError(app.action({ type: 'dialog_submission' }, async ({ say }) => say())); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.action('action_id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.action('action_id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/command.test-d.ts b/test/types/command.test-d.ts new file mode 100644 index 000000000..647971698 --- /dev/null +++ b/test/types/command.test-d.ts @@ -0,0 +1,23 @@ +import { expectAssignable, expectType } from 'tsd'; +import type { SlashCommand } from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.command('/hello', async ({ command }) => { + expectType(command); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.command('/action', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.command('/action', async ({ context }) => { + expectAssignable(context); +}); diff --git a/types-tests/error.test-d.ts b/test/types/error.test-d.ts similarity index 69% rename from types-tests/error.test-d.ts rename to test/types/error.test-d.ts index db25f3e07..08cda23a9 100644 --- a/types-tests/error.test-d.ts +++ b/test/types/error.test-d.ts @@ -1,8 +1,8 @@ -import App from '../src/App'; +import type { IncomingMessage, ServerResponse } from 'node:http'; import { expectType } from 'tsd'; -import { CodedError } from '../src/errors'; -import { IncomingMessage, ServerResponse } from 'http'; -import { BufferedIncomingMessage } from '../src/receivers/BufferedIncomingMessage'; +import App from '../../src/App'; +import type { CodedError } from '../../src/errors'; +import type { BufferedIncomingMessage } from '../../src/receivers/BufferedIncomingMessage'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); @@ -12,31 +12,31 @@ app.error(async (error) => { expectType(error); expectType(error.original); - if (error.original != undefined) { + if (error.original !== undefined) { expectType(error.original); console.log(error.original.message); } expectType(error.originals); - if (error.originals != undefined) { + if (error.originals !== undefined) { expectType(error.originals); console.log(error.originals); } expectType(error.missingProperty); - if (error.missingProperty != undefined) { + if (error.missingProperty !== undefined) { expectType(error.missingProperty); console.log(error.missingProperty); } expectType(error.req); - if (error.req != undefined) { + if (error.req !== undefined) { expectType(error.req); console.log(error.req); } expectType(error.res); - if (error.res != undefined) { + if (error.res !== undefined) { expectType(error.res); console.log(error.res); } diff --git a/test/types/event.test-d.ts b/test/types/event.test-d.ts new file mode 100644 index 000000000..89e6faeda --- /dev/null +++ b/test/types/event.test-d.ts @@ -0,0 +1,69 @@ +import type { + AppMentionEvent, + MessageEvent, + PinAddedEvent, + PinRemovedEvent, + ReactionAddedEvent, + ReactionRemovedEvent, + UserHuddleChangedEvent, + UserProfileChangedEvent, + UserStatusChangedEvent, +} from '@slack/types'; +import { expectType } from 'tsd'; +import type { SayFn } from '../../'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.event('message', async ({ event, say, message }) => { + expectType(event); + expectType(message); + expectType(say); +}); + +app.event('app_mention', async ({ event }) => { + expectType(event); +}); + +app.event('reaction_added', async ({ event }) => { + expectType(event); +}); + +app.event('reaction_removed', async ({ event }) => { + expectType(event); +}); + +app.event('user_huddle_changed', async ({ event }) => { + expectType(event); +}); + +app.event('user_profile_changed', async ({ event }) => { + expectType(event); +}); + +app.event('user_status_changed', async ({ event }) => { + expectType(event); +}); + +app.event('pin_added', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +app.event('pin_removed', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +app.event('reaction_added', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +app.event('reaction_removed', async ({ say, event }) => { + expectType(say); + expectType(event); +}); + +// TODO: we should not allow providing bogus event names +// app.event('garbage', async ({ event }) => {}); diff --git a/test/types/message.test-d.ts b/test/types/message.test-d.ts new file mode 100644 index 000000000..94c0177df --- /dev/null +++ b/test/types/message.test-d.ts @@ -0,0 +1,111 @@ +import type { + AllMessageEvents, + BotMessageEvent, + EKMAccessDeniedMessageEvent, + GenericMessageEvent, + MeMessageEvent, + MessageChangedEvent, + MessageDeletedEvent, + MessageEvent, + MessageRepliedEvent, + ThreadBroadcastMessageEvent, +} from '@slack/types'; +import { expectAssignable, expectError, expectNotType, expectType } from 'tsd'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// TODO: asserting on the types of event sub-properties is a responsibility of the `@slack/types` package, not bolt. +// e.g. message.user, message.team, etc. +// +// Types for generic message listeners, i.e. MessageEvent +app.message(async ({ message }) => { + expectType(message); + + expectType(message.channel); + // The type here is still a union type of all the possible subtyped events. + // Thus, only the fields available for all the types can be resolved outside if/else statements. + expectError(message.user); + + // TODO: what if subtype is not on the message at all? e.g. !('subtype' in message) + if (message.subtype === undefined) { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.user); + expectType(message.channel); + // TODO: should this be potentially undefined? Not all of the various Message event subtype interfaces + // (in @slack/types) have a `team` property - is that correct? Also the GenericMessageEvent has team as optional. + expectType(message.team); + } + if (message.subtype === 'bot_message') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + // TODO: should this be potentially undefined? + expectType(message.user); + expectType(message.channel); + } + if (message.subtype === 'ekm_access_denied') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.channel); + } + if (message.subtype === 'me_message') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.user); + expectType(message.channel); + } + if (message.subtype === 'message_replied') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.channel); + expectType(message.message.thread_ts); + // expectType(message.message.text); // TODO: womp womp https://github.com/slackapi/bolt-js/issues/1572 + } + if (message.subtype === 'message_changed') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.channel); + expectType(message.message); + } + if (message.subtype === 'message_deleted') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.channel); + expectType(message.ts); + } + if (message.subtype === 'thread_broadcast') { + expectType(message); + expectNotType(message); + // TODO: move these assertions to `@slack/types` + expectType(message.channel); + // TODO: this being potentially undefined seems wrong! + expectType(message.thread_ts); + expectType(message.ts); + // TODO: the actual type here seems wrong... + expectNotType(message.root); + } + + await Promise.resolve(message); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.message(async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.message(async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/options.test-d.ts b/test/types/options.test-d.ts new file mode 100644 index 000000000..07abe7478 --- /dev/null +++ b/test/types/options.test-d.ts @@ -0,0 +1,102 @@ +import type { Option } from '@slack/types'; +import { expectAssignable, expectType } from 'tsd'; +import type { + AckFn, + BlockOptions, + BlockSuggestion, + DialogOptionGroups, + DialogOptions, + DialogSuggestion, + InteractiveMessageSuggestion, + MessageOptions, + OptionGroups, +} from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +app.options('action-id-or-callback-id', async ({ options, ack }) => { + // TODO: should BlockSuggestion belong in types package? if so, assertions on its contents should also move to types package. + // defaults options to block_suggestion + expectType(options); + // biome-ignore lint/suspicious/noExplicitAny: TODO: should the callback ID be any? seems wrong + expectType(options.callback_id); + options.block_id; + options.action_id; + // ack should allow either BlockOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); + +// FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not constrain the arguments of the handler down to `block_suggestion` + +// interactive_message (attachments) +app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => { + expectType(options); + // ack should allow either MessageOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); + +// FIXME: app.options({ type: 'interactive_message', callback_id: 'a' } does not constrain the arguments of the handler down to `interactive_message` + +// dialog_suggestion (dialog) +app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => { + expectType(options); + // ack should allow either MessageOptions or OptionGroups + // https://github.com/slackapi/bolt-js/issues/720 + expectAssignable>(ack); + expectAssignable>>(ack); +}); +// FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not constrain the arguments of the handler down to `dialog_sggestion` + +const db = { + get: (_teamId: string) => { + return [{ label: 'l', value: 'v' }]; + }, +}; + +// Taken from https://slack.dev/bolt-js/concepts#options +// Example of responding to an external_select options request +app.options('external_action', async ({ options, ack }) => { + // Get information specific to a team or channel + // TODO: modified to satisfy TS compiler; should team be optional? + const results = options.team != null ? db.get(options.team.id) : []; + + if (results) { + // (modified to satisfy TS compiler) + const options: Option[] = []; + // Collect information in options array to send in Slack ack response + for (const result of results) { + options.push({ + text: { + type: 'plain_text', + text: result.label, + }, + value: result.value, + }); + } + + await ack({ + options: options, + }); + } else { + await ack(); + } +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.options<'block_suggestion', MyContext>('suggest', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.options('suggest', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/shortcut.test-d.ts b/test/types/shortcut.test-d.ts new file mode 100644 index 000000000..1682568ba --- /dev/null +++ b/test/types/shortcut.test-d.ts @@ -0,0 +1,62 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { GlobalShortcut, MessageShortcut, SayFn, SlackShortcut } from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// calling shortcut method with incorrect type constraint value should not work +expectError( + app.shortcut({ type: 'Something wrong' }, async ({ shortcut }) => { + await Promise.resolve(shortcut); + }), +); + +// Shortcut in listener should be MessageShortcut if constraint is type:message_action +app.shortcut({ type: 'message_action' }, async ({ shortcut, say }) => { + expectType(shortcut); + expectType(say); +}); + +// If shortcut is parameterized with MessageShortcut, shortcut argument in callback should be type MessageShortcut +app.shortcut({}, async ({ shortcut, say }) => { + expectType(shortcut); + expectType(say); +}); + +// If the constraint is unspecific, say will be unavailable +expectError(app.shortcut({}, async ({ say }) => say())); + +// If the constraint is unspecific, the shortcut is the more general SlackShortcut type +app.shortcut({}, async ({ shortcut }) => { + expectType(shortcut); +}); + +// `say` in listener should be unavailable if constraint is type:shortcut +expectError(app.shortcut({ type: 'shortcut' }, async ({ say }) => say())); + +// Shortcut in listener should be GlobalShortcut if constraint is type:shortcut +app.shortcut({ type: 'shortcut' }, async ({ shortcut }) => { + expectType(shortcut); +}); + +// If shortcut is parameterized with GlobalShortcut, say argument in callback should not be available +expectError(app.shortcut({}, async ({ say }) => say())); + +// If shortcut is parameterized with GlobalShortcut, shortcut parameter should be of type GlobalShortcut +app.shortcut({}, async ({ shortcut }) => { + expectType(shortcut); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.shortcut('callback_id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.shortcut('callback_id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/use.test-d.ts b/test/types/use.test-d.ts new file mode 100644 index 000000000..ce349b9e0 --- /dev/null +++ b/test/types/use.test-d.ts @@ -0,0 +1,31 @@ +import { expectAssignable } from 'tsd'; +import App from '../../src/App'; +import { onlyCommands, onlyViewActions } from '../../src/middleware/builtin'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// Ensure you can use some of the built-in middleware as global middleware +// https://github.com/slackapi/bolt-js/issues/911 +app.use(onlyViewActions); +app.use(onlyCommands); +app.use(async ({ ack, next }) => { + if (ack) { + await ack(); + return; + } + await next(); +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.use(async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.use(async ({ context }) => { + expectAssignable(context); +}); diff --git a/test/types/view.test-d.ts b/test/types/view.test-d.ts new file mode 100644 index 000000000..7fe502af3 --- /dev/null +++ b/test/types/view.test-d.ts @@ -0,0 +1,63 @@ +import { expectAssignable, expectError, expectType } from 'tsd'; +import type { SlackViewAction, ViewOutput } from '../..'; +import App from '../../src/App'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// invalid view constraints +expectError( + app.view( + { + callback_id: 'foo', + type: 'view_submission', + unknown_key: 'should be detected', + }, + async () => {}, + ), +); +expectError( + app.view( + { + callback_id: 'foo', + type: undefined, + unknown_key: 'should be detected', + }, + async () => {}, + ), +); +// view_submission +app.view('modal-id', async ({ body, view }) => { + // TODO: the body can be more specific (ViewSubmitAction) here + expectType(body); + expectType(view); + // TODO: assert on type assignability for `ack` +}); + +app.view({ type: 'view_submission', callback_id: 'modal-id' }, async ({ body, view }) => { + // TODO: the body can be more specific (ViewSubmitAction) here. need to add a type parameter (generic) to view() and 'link' constraint w/ view types. + expectType(body); + expectType(view); + // TODO: assert on type assignability for `ack` +}); + +// view_closed +app.view({ type: 'view_closed', callback_id: 'modal-id' }, async ({ body, view }) => { + // TODO: the body can be more specific (ViewClosedAction) here. need to add a type parameter (generic) to view() and 'link' constraint w/ view types. + expectType(body); + expectType(view); + // TODO: assert on type assignability for `ack` +}); + +interface MyContext { + doesnt: 'matter'; +} +// Ensure custom context assigned to individual middleware is honoured +app.view('view-id', async ({ context }) => { + expectAssignable(context); +}); + +// Ensure custom context assigned to the entire app is honoured +const typedContextApp = new App(); +typedContextApp.view('view-id', async ({ context }) => { + expectAssignable(context); +}); diff --git a/.mocharc.json b/test/unit/.mocharc.json similarity index 69% rename from .mocharc.json rename to test/unit/.mocharc.json index 09b509473..3958af49e 100644 --- a/.mocharc.json +++ b/test/unit/.mocharc.json @@ -1,4 +1,5 @@ { "require": ["ts-node/register", "source-map-support/register"], + "spec": ["test/unit/**/*.spec.ts"], "timeout": 3000 } diff --git a/test/unit/App/basic.spec.ts b/test/unit/App/basic.spec.ts new file mode 100644 index 000000000..271283100 --- /dev/null +++ b/test/unit/App/basic.spec.ts @@ -0,0 +1,377 @@ +import { LogLevel } from '@slack/logger'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { ErrorCode } from '../../../src/errors'; +import SocketModeReceiver from '../../../src/receivers/SocketModeReceiver'; +import { + FakeReceiver, + createFakeConversationStore, + createFakeLogger, + importApp, + mergeOverrides, + noop, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, + withSuccessfulBotUserFetchingWebClient, +} from '../helpers'; + +const fakeAppToken = 'xapp-1234'; +const fakeBotId = 'B_FAKE_BOT_ID'; +const fakeBotUserId = 'U_FAKE_BOT_USER_ID'; + +describe('App basic features', () => { + const overrides = mergeOverrides( + withNoopAppMetadata(), + withSuccessfulBotUserFetchingWebClient(fakeBotId, fakeBotUserId), + ); + + describe('constructor', () => { + describe('with a custom port value in HTTP Mode', () => { + it('should accept a port value at the top-level', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: '', signingSecret: '', port: 9999 }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'port', 9999); + }); + it('should accept a port value under installerOptions', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: '', signingSecret: '', port: 7777, installerOptions: { port: 9999 } }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'port', 9999); + }); + }); + + describe('with a custom port value in Socket Mode', () => { + const installationStore = { + storeInstallation: async () => {}, + fetchInstallation: async () => { + throw new Error('Failed fetching installation'); + }, + deleteInstallation: async () => {}, + }; + it('should accept a port value at the top-level', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ + socketMode: true, + appToken: fakeAppToken, + port: 9999, + clientId: '', + clientSecret: '', + stateSecret: '', + installerOptions: {}, + installationStore, + }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'httpServerPort', 9999); + }); + it('should accept a port value under installerOptions', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ + socketMode: true, + appToken: fakeAppToken, + port: 7777, + clientId: '', + clientSecret: '', + stateSecret: '', + installerOptions: { + port: 9999, + }, + installationStore, + }); + // biome-ignore lint/complexity/useLiteralKeys: reaching into private fields + assert.propertyVal(app['receiver'], 'httpServerPort', 9999); + }); + }); + + // TODO: test when the single team authorization results fail. that should still succeed but warn. it also means + // that the `ignoreSelf` middleware will fail (or maybe just warn) a bunch. + describe('with successful single team authorization results', () => { + it('should succeed with a token for single team authorization', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: '', signingSecret: '' }); + // TODO: verify that the fake bot ID and fake bot user ID are retrieved + assert.instanceOf(app, MockApp); + }); + it('should pass the given token to app.client', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ token: 'xoxb-foo-bar', signingSecret: '' }); + assert.isDefined(app.client); + assert.equal(app.client.token, 'xoxb-foo-bar'); + }); + }); + it('should succeed with an authorize callback', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + new MockApp({ authorize: authorizeCallback, signingSecret: '' }); + assert(authorizeCallback.notCalled, 'Should not call the authorize callback on instantiation'); + }); + it('should fail without a token for single team authorization, authorize callback, nor oauth installer', async () => { + const MockApp = await importApp(); + try { + new MockApp({ signingSecret: '' }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when both a token and authorize callback are specified', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + try { + new MockApp({ token: '', authorize: authorizeCallback, signingSecret: '' }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + it('should fail when both a token is specified and OAuthInstaller is initialized', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + try { + new MockApp({ token: '', clientId: '', clientSecret: '', stateSecret: '', signingSecret: '' }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + it('should fail when both a authorize callback is specified and OAuthInstaller is initialized', async () => { + const authorizeCallback = sinon.fake(); + const MockApp = await importApp(); + try { + new MockApp({ + authorize: authorizeCallback, + clientId: '', + clientSecret: '', + stateSecret: '', + signingSecret: '', + }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + assert(authorizeCallback.notCalled); + } + }); + describe('with a custom receiver', () => { + it('should succeed with no signing secret', async () => { + const MockApp = await importApp(); + new MockApp({ + receiver: new FakeReceiver(), + authorize: noop, + }); + }); + }); + it('should fail when no signing secret for the default receiver is specified', async () => { + const MockApp = await importApp(); + try { + new MockApp({ authorize: noop }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when both socketMode and a custom receiver are specified', async () => { + const fakeReceiver = new FakeReceiver(); + const MockApp = await importApp(); + try { + new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: fakeReceiver }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should succeed when both socketMode and SocketModeReceiver are specified', async () => { + const MockApp = await importApp(overrides); + const socketModeReceiver = new SocketModeReceiver({ appToken: fakeAppToken }); + new MockApp({ token: '', signingSecret: '', socketMode: true, receiver: socketModeReceiver }); + }); + it('should initialize MemoryStore conversation store by default', async () => { + const fakeMemoryStore = sinon.fake(); + const fakeConversationContext = sinon.fake.returns(noopMiddleware); + const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withMemoryStore(fakeMemoryStore), + withConversationContext(fakeConversationContext), + ); + const MockApp = await importApp(overrides); + + new MockApp({ authorize: noop, signingSecret: '' }); + assert(fakeMemoryStore.calledWithNew); + assert(fakeConversationContext.called); + }); + describe('conversation store', () => { + const fakeConversationContext = sinon.fake.returns(noopMiddleware); + const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withConversationContext(fakeConversationContext), + ); + it('should initialize without a conversation store when option is false', async () => { + const MockApp = await importApp(overrides); + new MockApp({ convoStore: false, authorize: noop, signingSecret: '' }); + assert(fakeConversationContext.notCalled); + }); + it('should initialize the conversation store', async () => { + const dummyConvoStore = createFakeConversationStore(); + const MockApp = await importApp(overrides); + const app = new MockApp({ convoStore: dummyConvoStore, authorize: noop, signingSecret: '' }); + assert.instanceOf(app, MockApp); + assert(fakeConversationContext.firstCall.calledWith(dummyConvoStore)); + }); + }); + describe('with custom redirectUri supplied', () => { + it('should fail when missing installerOptions', async () => { + const MockApp = await importApp(); + try { + new MockApp({ token: '', signingSecret: '', redirectUri: 'http://example.com/redirect' }); // eslint-disable-line no-new + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + it('should fail when missing installerOptions.redirectUriPath', async () => { + const MockApp = await importApp(); + try { + new MockApp({ + token: '', + signingSecret: '', + redirectUri: 'http://example.com/redirect', + installerOptions: {}, + }); + assert.fail(); + } catch (error) { + assert.propertyVal(error, 'code', ErrorCode.AppInitializationError); + } + }); + }); + it('with WebClientOptions', async () => { + const fakeConstructor = sinon.fake(); + const overrides = mergeOverrides(withNoopAppMetadata(), { + '@slack/web-api': { + WebClient: class { + // biome-ignore lint/suspicious/noExplicitAny: test overrides can be anything + public constructor(...args: any[]) { + fakeConstructor(...args); + } + }, + }, + }); + + const MockApp = await importApp(overrides); + const clientOptions = { slackApiUrl: 'proxy.slack.com' }; + new MockApp({ clientOptions, authorize: noop, signingSecret: '', logLevel: LogLevel.ERROR }); + assert.ok(fakeConstructor.called); + const [token, options] = fakeConstructor.lastCall.args; + assert.strictEqual(token, undefined, 'token should be undefined'); + assert.strictEqual(clientOptions.slackApiUrl, options.slackApiUrl); + assert.strictEqual(LogLevel.ERROR, options.logLevel, 'override logLevel'); + }); + describe('with auth.test failure', () => { + const fakeConstructor = sinon.fake(); + const exception = 'This API method call should not be performed'; + const overrides = mergeOverrides(withNoopAppMetadata(), { + '@slack/web-api': { + WebClient: class { + // biome-ignore lint/suspicious/noExplicitAny: test overrides can be anything + public constructor(...args: any[]) { + fakeConstructor(args); + } + + public auth = { + test: () => { + throw new Error(exception); + }, + }; + }, + }, + }); + it('should not perform auth.test API call if tokenVerificationEnabled is false', async () => { + const MockApp = await importApp(overrides); + new MockApp({ + token: 'xoxb-completely-invalid-token', + signingSecret: 'invalid-one', + tokenVerificationEnabled: false, + }); + }); + + it('should fail in await App#init()', async () => { + const MockApp = await importApp(overrides); + const app = new MockApp({ + token: 'xoxb-completely-invalid-token', + signingSecret: 'invalid-one', + deferInitialization: true, + }); + assert.instanceOf(app, MockApp); + try { + await app.start(); + assert.fail('The start() method should fail before init() call'); + } catch (err) { + assert.propertyVal( + err, + 'message', + 'This App instance is not yet initialized. Call `await App#init()` before starting the app.', + ); + } + try { + await app.init(); + assert.fail('The init() method should fail here'); + } catch (err) { + console.log(err); + assert.propertyVal(err, 'message', exception); + } + }); + }); + + describe('with developerMode', () => { + it('should accept developerMode: true', async () => { + const overrides = mergeOverrides( + withNoopAppMetadata(), + withSuccessfulBotUserFetchingWebClient('B_FAKE_BOT_ID', 'U_FAKE_BOT_USER_ID'), + ); + const fakeLogger = createFakeLogger(); + const MockApp = await importApp(overrides); + const app = new MockApp({ logger: fakeLogger, token: '', appToken: fakeAppToken, developerMode: true }); + assert.propertyVal(app, 'logLevel', LogLevel.DEBUG); + assert.propertyVal(app, 'socketMode', true); + }); + }); + + // TODO: tests for logger and logLevel option + // TODO: tests for providing botId and botUserId options + // TODO: tests for providing endpoints option + }); + + describe('#start', () => { + it('should pass calls through to receiver', async () => { + // Arrange + const dummyReturn = Symbol(); + const fakeReceiver = new FakeReceiver(); + const MockApp = await importApp(); + const app = new MockApp({ receiver: fakeReceiver, authorize: noop }); + fakeReceiver.start = sinon.fake.returns(dummyReturn); + await app.start(1337); + assert.deepEqual(fakeReceiver.start.firstCall.args, [1337]); + }); + }); + + describe('#stop', () => { + it('should pass calls through to receiver', async () => { + const dummyReturn = Symbol(); + const dummyParams = [Symbol(), Symbol()]; + const fakeReceiver = new FakeReceiver(); + const MockApp = await importApp(); + fakeReceiver.stop = sinon.fake.returns(dummyReturn); + + const app = new MockApp({ receiver: fakeReceiver, authorize: noop }); + const actualReturn = await app.stop(...dummyParams); + + assert.deepEqual(actualReturn, dummyReturn); + assert.deepEqual(dummyParams, fakeReceiver.stop.firstCall.args); + }); + }); +}); diff --git a/test/unit/App/middleware.spec.ts b/test/unit/App/middleware.spec.ts new file mode 100644 index 000000000..4d87fc203 --- /dev/null +++ b/test/unit/App/middleware.spec.ts @@ -0,0 +1,1085 @@ +import type { WebClient } from '@slack/web-api'; +import { assert } from 'chai'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { type ExtendedErrorHandlerArgs, LogLevel } from '../../../src/App'; +import { AuthorizationError, type CodedError, ErrorCode, UnknownError, isCodedError } from '../../../src/errors'; +import type { NextFn, ReceiverEvent, SayFn } from '../../../src/types'; +import { + FakeReceiver, + type Override, + createDummyAppMentionEventMiddlewareArgs, + createDummyBlockActionEventMiddlewareArgs, + createDummyMessageEventMiddlewareArgs, + createDummyReceiverEvent, + createDummyViewSubmissionMiddlewareArgs, + createFakeLogger, + delay, + importApp, + mergeOverrides, + noop, + noopMiddleware, + noopVoid, + withAxiosPost, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, + withPostMessage, + withSuccessfulBotUserFetchingWebClient, +} from '../helpers'; + +describe('App middleware processing', () => { + let fakeReceiver: FakeReceiver; + let fakeErrorHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + + beforeEach(() => { + fakeReceiver = new FakeReceiver(); + fakeErrorHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + }); + + // TODO: verify that authorize callback is called with the correct properties and responds correctly to + // various return values + + function createInvalidReceiverEvents(): ReceiverEvent[] { + // TODO: create many more invalid receiver events (fuzzing) + return [ + { + body: {}, + ack: sinon.fake.resolves(undefined), + }, + ]; + } + // TODO: tests for ignoreSelf option + + it('should warn and skip when processing a receiver event with unknown type (never crash)', async () => { + const fakeLogger = createFakeLogger(); + const fakeMiddleware = sinon.fake(noopMiddleware); + const invalidReceiverEvents = createInvalidReceiverEvents(); + const MockApp = await importApp(); + + const app = new MockApp({ receiver: fakeReceiver, logger: fakeLogger, authorize: noop }); + app.use(fakeMiddleware); + await Promise.all(invalidReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert(fakeErrorHandler.notCalled); + assert(fakeMiddleware.notCalled); + assert.isAtLeast(fakeLogger.warn.callCount, invalidReceiverEvents.length); + }); + + it('should warn, send to global error handler, and skip when a receiver event fails authorization', async () => { + const fakeLogger = createFakeLogger(); + const fakeMiddleware = sinon.fake(noopMiddleware); + const dummyOrigError = new Error('auth failed'); + const dummyAuthorizationError = new AuthorizationError('auth failed', dummyOrigError); + const dummyReceiverEvent = createDummyReceiverEvent(); + const MockApp = await importApp(); + + const app = new MockApp({ + receiver: fakeReceiver, + logger: fakeLogger, + authorize: sinon.fake.rejects(dummyAuthorizationError), + }); + app.use(fakeMiddleware); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert(fakeMiddleware.notCalled); + assert(fakeLogger.warn.called); + assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); + assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'code', ErrorCode.AuthorizationError); + assert.propertyVal(fakeErrorHandler.firstCall.args[0], 'original', dummyAuthorizationError.original); + }); + + describe('global middleware', () => { + let fakeFirstMiddleware: SinonSpy; + let fakeSecondMiddleware: SinonSpy; + let app: App; + let dummyReceiverEvent: ReceiverEvent; + + beforeEach(async () => { + const fakeConversationContext = sinon.fake.returns(noopMiddleware); + const overrides = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + withMemoryStore(sinon.fake()), + withConversationContext(fakeConversationContext), + ); + const MockApp = await importApp(overrides); + + dummyReceiverEvent = createDummyReceiverEvent(); + fakeFirstMiddleware = sinon.fake(noopMiddleware); + fakeSecondMiddleware = sinon.fake(noopMiddleware); + + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should error if next called multiple times', async () => { + // Arrange + app.use(fakeFirstMiddleware); + app.use(async ({ next }) => { + await next(); + await next(); + }); + app.use(fakeSecondMiddleware); + app.error(fakeErrorHandler); + + // Act + await fakeReceiver.sendEvent(dummyReceiverEvent); + + // Assert + assert.instanceOf(fakeErrorHandler.firstCall.args[0], Error); + }); + + it('correctly waits for async listeners', async () => { + let changed = false; + + app.use(async ({ next }) => { + await delay(10); + changed = true; + + await next(); + }); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + assert.isTrue(changed); + assert(fakeErrorHandler.notCalled); + }); + + it('throws errors which can be caught by upstream async listeners', async () => { + const thrownError = new Error('Error handling the message :('); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + let caughtError: any; + + app.use(async ({ next }) => { + try { + await next(); + } catch (err) { + caughtError = err; + } + }); + + app.use(async () => { + throw thrownError; + }); + + app.error(fakeErrorHandler); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert.equal(caughtError, thrownError); + assert(fakeErrorHandler.notCalled); + }); + + it('calls async middleware in declared order', async () => { + const message = ':wave:'; + let middlewareCount = 0; + + /** + * Middleware that, when called, asserts that it was called in the correct order + * @param orderDown The order it should be called when processing middleware down the chain + * @param orderUp The order it should be called when processing middleware up the chain + */ + const assertOrderMiddleware = + (orderDown: number, orderUp: number) => + async ({ next }: { next?: NextFn }) => { + await delay(10); + middlewareCount += 1; + assert.equal(middlewareCount, orderDown); + if (next !== undefined) { + await next(); + } + middlewareCount += 1; + assert.equal(middlewareCount, orderUp); + }; + + app.use(assertOrderMiddleware(1, 8)); + app.message(message, assertOrderMiddleware(3, 6), assertOrderMiddleware(4, 5)); + app.use(assertOrderMiddleware(2, 7)); + app.error(fakeErrorHandler); + + await fakeReceiver.sendEvent({ + ...dummyReceiverEvent, + body: { + type: 'event_callback', + event: { + type: 'message', + text: message, + }, + }, + }); + + assert.equal(middlewareCount, 8); + assert(fakeErrorHandler.notCalled); + }); + + it('should, on error, call the global error handler, not extended', async () => { + const error = new Error('Everything is broke, you probably should restart, if not then good luck'); + + app.use(() => { + throw error; + }); + + app.error(async (codedError: CodedError) => { + assert.instanceOf(codedError, UnknownError); + assert.equal(codedError.message, error.message); + }); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + }); + + it('should, on error, call the global error handler, extended', async () => { + const error = new Error('Everything is broke, you probably should restart, if not then good luck'); + // biome-ignore lint/complexity/useLiteralKeys: Accessing through bracket notation because it is private (for testing purposes) + app['extendedErrorHandler'] = true; + + app.use(() => { + throw error; + }); + + app.error(async (args: ExtendedErrorHandlerArgs) => { + assert.property(args, 'error'); + assert.property(args, 'body'); + assert.property(args, 'context'); + assert.property(args, 'logger'); + assert.isDefined(args.error); + assert.isDefined(args.body); + assert.isDefined(args.context); + assert.isDefined(args.logger); + assert.equal(args.error.message, error.message); + }); + + await fakeReceiver.sendEvent(dummyReceiverEvent); + + // biome-ignore lint/complexity/useLiteralKeys: Accessing through bracket notation because it is private (for testing purposes) + app['extendedErrorHandler'] = false; + }); + + it('with a default global error handler, rejects App#ProcessEvent', async () => { + const error = new Error('The worst has happened, bot is beyond saving, always hug servers'); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything + let actualError: any; + + app.use(() => { + throw error; + }); + + try { + await fakeReceiver.sendEvent(dummyReceiverEvent); + } catch (err) { + actualError = err; + } + + assert.instanceOf(actualError, UnknownError); + assert.equal(actualError.message, error.message); + }); + }); + + describe('listener middleware', () => { + let app: App; + const eventType = 'some_event_type'; + const dummyReceiverEvent = createDummyReceiverEvent(eventType); + + beforeEach(async () => { + const MockAppNoOverrides = await importApp(); + app = new MockAppNoOverrides({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.error(fakeErrorHandler); + }); + + it('should bubble up errors in listeners to the global error handler', async () => { + const errorToThrow = new Error('listener error'); + + app.event(eventType, async () => { + throw errorToThrow; + }); + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert(fakeErrorHandler.calledOnce); + const error = fakeErrorHandler.firstCall.args[0]; + assert.equal(error.code, ErrorCode.UnknownError); + assert.equal(error.original, errorToThrow); + }); + + it('should aggregate multiple errors in listeners for the same incoming event', async () => { + const errorsToThrow = [new Error('first listener error'), new Error('second listener error')]; + function createThrowingListener(toBeThrown: Error): () => Promise { + return async () => { + throw toBeThrown; + }; + } + + app.event(eventType, createThrowingListener(errorsToThrow[0])); + app.event(eventType, createThrowingListener(errorsToThrow[1])); + await fakeReceiver.sendEvent(dummyReceiverEvent); + + assert(fakeErrorHandler.calledOnce); + const error = fakeErrorHandler.firstCall.args[0]; + assert.ok(isCodedError(error)); + assert(error.code === ErrorCode.MultipleListenerError); + assert.isArray(error.originals); + if (error.originals) assert.sameMembers(error.originals, errorsToThrow); + }); + + // https://github.com/slackapi/bolt-js/issues/1457 + it('should not cause a runtime exception if the last listener middleware invokes next()', async () => { + await new Promise((resolve, reject) => { + app.event('app_mention', async ({ next }) => { + try { + await next(); + resolve(); + } catch (e) { + reject(e); + } + }); + fakeReceiver.sendEvent(createDummyReceiverEvent('app_mention')); + }); + }); + }); + + describe('middleware and listener arguments', () => { + let overrides: Override; + const dummyChannelId = 'CHANNEL_ID'; + const baseEvent = createDummyReceiverEvent(); + + function buildOverrides(secondOverrides: Override[]): Override { + overrides = mergeOverrides( + withNoopAppMetadata(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); + return overrides; + } + + describe('authorize', () => { + it('should extract valid enterprise_id in a shared channel #935', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + let workedAsExpected = false; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: async ({ enterpriseId }) => { + if (enterpriseId !== undefined) { + throw new Error('the enterprise_id must be undefined in this scenario'); + } + return dummyAuthorizationResult; + }, + }); + app.event('message', async () => { + workedAsExpected = true; + }); + await fakeReceiver.sendEvent({ + ack: noopVoid, + ...createDummyMessageEventMiddlewareArgs( + {}, + { + authorizations: [ + { + enterprise_id: null, + team_id: 'T_this_non_grid_workspace', + user_id: 'U_authed_user', + is_bot: true, + is_enterprise_install: false, + }, + ], + }, + ), + }); + + assert.isTrue(workedAsExpected); + }); + it('should be skipped for tokens_revoked events #674', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + let workedAsExpected = false; + let authorizeCallCount = 0; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: async () => { + authorizeCallCount += 1; + return {}; + }, + }); + app.event('tokens_revoked', async () => { + workedAsExpected = true; + }); + + // The authorize must be called for other events + await fakeReceiver.sendEvent({ + ack: noopVoid, + ...createDummyAppMentionEventMiddlewareArgs(), + }); + assert.equal(authorizeCallCount, 1); + + await fakeReceiver.sendEvent({ + ack: noopVoid, + body: { + enterprise_id: 'E_org_id', + api_app_id: 'A111', + event: { + type: 'tokens_revoked', + tokens: { + oauth: ['P'], + bot: ['B'], + }, + }, + type: 'event_callback', + }, + }); + + assert.equal(authorizeCallCount, 1); // still 1 + assert.isTrue(workedAsExpected); + }); + it('should be skipped for app_uninstalled events #674', async () => { + const fakeAxiosPost = sinon.fake.resolves({}); + overrides = buildOverrides([withNoopWebClient(), withAxiosPost(fakeAxiosPost)]); + const MockApp = await importApp(overrides); + + let workedAsExpected = false; + let authorizeCallCount = 0; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: async () => { + authorizeCallCount += 1; + return {}; + }, + }); + app.event('app_uninstalled', async () => { + workedAsExpected = true; + }); + + // The authorize must be called for other events + await fakeReceiver.sendEvent({ + ack: noopVoid, + ...createDummyAppMentionEventMiddlewareArgs(), + }); + assert.equal(authorizeCallCount, 1); + + await fakeReceiver.sendEvent({ + ack: noopVoid, + body: { + enterprise_id: 'E_org_id', + api_app_id: 'A111', + event: { + type: 'app_uninstalled', + }, + type: 'event_callback', + }, + }); + + assert.equal(authorizeCallCount, 1); // still 1 + assert.isTrue(workedAsExpected); + }); + }); + + describe('respond()', () => { + it('should respond to events with a response_url', async () => { + 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 MockApp = await importApp(overrides); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.action(action_id, async ({ respond }) => { + await respond(responseText); + }); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent( + createDummyBlockActionEventMiddlewareArgs( + { + action: { + type: 'button', + action_id, + block_id: 'bid', + action_ts: '1', + text: { type: 'plain_text', text: 'hi' }, + }, + }, + { + response_url, + }, + ), + ); + + assert(fakeErrorHandler.notCalled); + assert.equal(fakeAxiosPost.callCount, 1); + // Assert that each call to fakeAxiosPost had the right arguments + sinon.assert.calledWith(fakeAxiosPost, response_url, { 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 MockApp = await importApp(overrides); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.action(action_id, async ({ respond }) => { + await respond(responseObject); + }); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent( + createDummyBlockActionEventMiddlewareArgs( + { + action: { + type: 'button', + action_id, + block_id: 'bid', + action_ts: '1', + text: { type: 'plain_text', text: 'hi' }, + }, + }, + { + response_url, + }, + ), + ); + + assert.equal(fakeAxiosPost.callCount, 1); + // Assert that each call to fakeAxiosPost had the right arguments + sinon.assert.calledWith(fakeAxiosPost, response_url, 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 MockApp = await importApp(overrides); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.view('view-id', async ({ respond }) => { + await respond(responseObject); + }); + app.error(fakeErrorHandler); + await fakeReceiver.sendEvent( + createDummyViewSubmissionMiddlewareArgs( + { + id: 'V111', + type: 'modal', + callback_id: 'view-id', + }, + { + response_urls: [ + { + block_id: 'b', + action_id: 'a', + channel_id: 'C111', + response_url: 'https://fake.slack/response_url', + }, + ], + }, + ), + ); + + assert.equal(fakeAxiosPost.callCount, 1); + // Assert that each call to fakeAxiosPost had the right arguments + assert(fakeAxiosPost.calledWith(responseUrl, responseObject)); + }); + }); + + describe('logger', () => { + it('should be available in middleware/listener args', async () => { + const MockApp = await importApp(overrides); + const fakeLogger = createFakeLogger(); + const app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(async ({ logger, body, next }) => { + logger.info(body); + await next(); + }); + + app.event('app_home_opened', async ({ logger, event }) => { + logger.debug(event); + }); + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }, + ]; + + await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.isTrue(fakeLogger.info.called); + assert.isTrue(fakeLogger.debug.called); + }); + + it('should work in the case both logger and logLevel are given', async () => { + const MockApp = await importApp(overrides); + const fakeLogger = createFakeLogger(); + const app = new MockApp({ + logger: fakeLogger, + logLevel: LogLevel.DEBUG, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(async ({ logger, body, next }) => { + logger.info(body); + await next(); + }); + + app.event('app_home_opened', async ({ logger, event }) => { + logger.debug(event); + }); + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }, + ]; + + await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.isTrue(fakeLogger.info.called); + assert.isTrue(fakeLogger.debug.called); + assert.isTrue(fakeLogger.setLevel.called); + }); + }); + + describe('client', () => { + it('should be available in middleware/listener args', async () => { + const MockApp = await importApp( + mergeOverrides(withNoopAppMetadata(), withSuccessfulBotUserFetchingWebClient('B123', 'U123')), + ); + const tokens = ['xoxb-123', 'xoxp-456', 'xoxb-123']; + const app = new MockApp({ + receiver: fakeReceiver, + authorize: () => { + const token = tokens.pop(); + if (typeof token === 'undefined') { + return Promise.resolve({ botId: 'B123' }); + } + if (token.startsWith('xoxb-')) { + return Promise.resolve({ botToken: token, botId: 'B123' }); + } + return Promise.resolve({ userToken: token, botId: 'B123' }); + }, + }); + app.use(async ({ client, next }) => { + await client.auth.test(); + await next(); + }); + const clients: WebClient[] = []; + app.event('app_home_opened', async ({ client }) => { + clients.push(client); + await client.auth.test(); + }); + + const event = { + body: { + type: 'event_callback', + token: 'legacy', + team_id: 'T123', + api_app_id: 'A123', + event: { + type: 'app_home_opened', + event_ts: '123.123', + user: 'U123', + text: 'Hi there!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }; + const receiverEvents = [event, event, event]; + + await Promise.all(receiverEvents.map((evt) => fakeReceiver.sendEvent(evt))); + + assert.isUndefined(app.client.token); + assert.equal(clients[0].token, 'xoxb-123'); + assert.equal(clients[1].token, 'xoxp-456'); + assert.equal(clients[2].token, 'xoxb-123'); + assert.notEqual(clients[0], clients[1]); + assert.strictEqual(clients[0], clients[2]); + }); + + it("should be set to the global app client when authorization doesn't produce a token", async () => { + const MockApp = await importApp(); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: noop, + ignoreSelf: false, + }); + const globalClient = app.client; + + let clientArg: WebClient | undefined; + app.use(async ({ client }) => { + clientArg = client; + }); + await fakeReceiver.sendEvent(createDummyReceiverEvent()); + + assert.equal(globalClient, clientArg); + }); + }); + + describe('say()', () => { + function createChannelContextualReceiverEvents(channelId: string): ReceiverEvent[] { + return [ + // IncomingEventType.Event with channel in payload + { + ...baseEvent, + body: { + event: { + channel: channelId, + }, + team_id: 'TEAM_ID', + }, + }, + // IncomingEventType.Event with channel in item + { + ...baseEvent, + body: { + event: { + item: { + channel: channelId, + }, + }, + team_id: 'TEAM_ID', + }, + }, + // IncomingEventType.Command + { + ...baseEvent, + body: { + command: '/COMMAND_NAME', + channel_id: channelId, + team_id: 'TEAM_ID', + }, + }, + // IncomingEventType.Action from block action, interactive message, or message action + { + ...baseEvent, + body: { + actions: [{}], + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + // IncomingEventType.Action from dialog submission + { + ...baseEvent, + body: { + type: 'dialog_submission', + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + ]; + } + describe('for events that should include say() utility', () => { + it('should send a simple message to a channel where the incoming event originates', async () => { + const fakePostMessage = sinon.fake.resolves({}); + overrides = buildOverrides([withPostMessage(fakePostMessage)]); + const MockApp = await importApp(overrides); + + const dummyMessage = 'test'; + const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: By definition, these events should all produce a say function, so we cast args.say into a SayFn + const say = (args as any).say as SayFn; + await say(dummyMessage); + }); + app.error(fakeErrorHandler); + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); + // Assert that each call to fakePostMessage had the right arguments + for (const call of fakePostMessage.getCalls()) { + const firstArg = call.args[0]; + assert.propertyVal(firstArg, 'text', dummyMessage); + assert.propertyVal(firstArg, 'channel', dummyChannelId); + } + assert(fakeErrorHandler.notCalled); + }); + + it('should send a complex message to a channel where the incoming event originates', async () => { + const fakePostMessage = sinon.fake.resolves({}); + overrides = buildOverrides([withPostMessage(fakePostMessage)]); + const MockApp = await importApp(overrides); + + const dummyMessage = { text: 'test' }; + const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: By definition, these events should all produce a say function, so we cast args.say into a SayFn + const say = (args as any).say as SayFn; + await say(dummyMessage); + }); + app.error(fakeErrorHandler); + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(fakePostMessage.callCount, dummyReceiverEvents.length); + // Assert that each call to fakePostMessage had the right arguments + for (const call of fakePostMessage.getCalls()) { + const firstArg = call.args[0]; + assert.propertyVal(firstArg, 'channel', dummyChannelId); + assert.propertyVal(firstArg, 'text', dummyMessage.text); + } + assert(fakeErrorHandler.notCalled); + }); + }); + + describe('for events that should not include say() utility', () => { + function createReceiverEventsWithoutSay(channelId: string): ReceiverEvent[] { + return [ + // IncomingEventType.Options from block action + { + ...baseEvent, + body: { + type: 'block_suggestion', + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + // IncomingEventType.Options from interactive message or dialog + { + ...baseEvent, + body: { + name: 'select_field_name', + channel: { + id: channelId, + }, + user: { + id: 'USER_ID', + }, + team: { + id: 'TEAM_ID', + }, + }, + }, + // IncomingEventType.Event without a channel context + { + ...baseEvent, + body: { + event: {}, + team_id: 'TEAM_ID', + }, + }, + ]; + } + + it("should not exist in the arguments on incoming events that don't support say", async () => { + overrides = buildOverrides([withNoopWebClient()]); + const MockApp = await importApp(overrides); + + const assertionAggregator = sinon.fake(); + const dummyReceiverEvents = createReceiverEventsWithoutSay(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + assert.notProperty(args, 'say'); + // If the above assertion fails, then it would throw an AssertionError and the following line will not be + // called + assertionAggregator(); + }); + + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(assertionAggregator.callCount, dummyReceiverEvents.length); + }); + + it("should handle failures through the App's global error handler", async () => { + const fakePostMessage = sinon.fake.rejects(new Error('fake error')); + overrides = buildOverrides([withPostMessage(fakePostMessage)]); + const MockApp = await importApp(overrides); + + const dummyMessage = { text: 'test' }; + const dummyReceiverEvents = createChannelContextualReceiverEvents(dummyChannelId); + + const app = new MockApp({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); + app.use(async (args) => { + // biome-ignore lint/suspicious/noExplicitAny: By definition, these events should all produce a say function, so we cast args.say into a SayFn + const say = (args as any).say as SayFn; + await say(dummyMessage); + }); + app.error(fakeErrorHandler); + await Promise.all(dummyReceiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.equal(fakeErrorHandler.callCount, dummyReceiverEvents.length); + }); + }); + }); + + describe('ack()', () => { + it('should be available in middleware/listener args', async () => { + const MockApp = await importApp(overrides); + const fakeLogger = createFakeLogger(); + const app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(async ({ ack, next }) => { + if (ack) { + // this should be called even if app.view listeners do not exist + await ack(); + return; + } + fakeLogger.info('Events API'); + await next(); + }); + + app.event('app_home_opened', async ({ logger, event }) => { + logger.debug(event); + }); + + let ackInMiddlewareCalled = false; + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noopVoid, + }, + { + body: { + type: 'view_submission', + team: {}, + user: {}, + view: { + id: 'V111', + type: 'modal', + callback_id: 'view-id', + state: {}, + title: {}, + close: {}, + submit: {}, + }, + }, + respond: noop, + ack: async () => { + ackInMiddlewareCalled = true; + }, + }, + ]; + + await Promise.all(receiverEvents.map((event) => fakeReceiver.sendEvent(event))); + + assert.isTrue(fakeLogger.info.called); + assert.isTrue(ackInMiddlewareCalled); + }); + }); + + 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)]); + const MockApp = await importApp(overrides); + const callback_id = 'view-id'; + const app_installed_team_id = 'T-installed-workspace'; + + const app = new MockApp({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + + let ackCalled = false; + app.view(callback_id, async ({ ack, context, view }) => { + assert.equal(context.teamId, app_installed_team_id); + assert.notEqual(view.team_id, app_installed_team_id); + await ack(); + ackCalled = true; + }); + app.error(fakeErrorHandler); + + await fakeReceiver.sendEvent( + createDummyViewSubmissionMiddlewareArgs({ + callback_id, + app_installed_team_id, + }), + ); + + assert.isTrue(ackCalled); + }); + }); + }); +}); diff --git a/test/unit/App/routing-action.spec.ts b/test/unit/App/routing-action.spec.ts new file mode 100644 index 000000000..53c0db7f0 --- /dev/null +++ b/test/unit/App/routing-action.spec.ts @@ -0,0 +1,81 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyBlockActionEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App action() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a block action event to a handler registered with `action(string)` that matches the action ID', async () => { + app.action('my_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs({ action_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block action event to a handler registered with `action(RegExp)` that matches the action ID', async () => { + app.action(/my_action/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs({ action_id: 'my_action' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block action event to a handler registered with `action({block_id})` that matches the block ID', async () => { + app.action({ block_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs({ block_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block action event to a handler registered with `action({type:block_actions})`', async () => { + app.action({ type: 'block_actions' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockActionEventMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + it('should throw if provided a constraint with unknown action constraint keys', async () => { + // @ts-ignore providing known invalid action constraint parameter + app.action({ id: 'boom' }, fakeHandler); + sinon.assert.calledWithMatch(fakeLogger.error, 'unknown constraint keys'); + }); +}); diff --git a/test/unit/App/routing-command.spec.ts b/test/unit/App/routing-command.spec.ts new file mode 100644 index 000000000..8c6315685 --- /dev/null +++ b/test/unit/App/routing-command.spec.ts @@ -0,0 +1,63 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyCommandMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + noopVoid, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App command() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a command to a handler registered with `command(string)` if command name matches', async () => { + app.command('/yo', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyCommandMiddlewareArgs({ command: '/yo' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should route a command to a handler registered with `command(RegExp)` if comand name matches', async () => { + app.command(/hi/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyCommandMiddlewareArgs({ command: '/hiya' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); +}); diff --git a/test/unit/App/routing-event.spec.ts b/test/unit/App/routing-event.spec.ts new file mode 100644 index 000000000..f9680fa57 --- /dev/null +++ b/test/unit/App/routing-event.spec.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyAppMentionEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + noopVoid, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App event() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a Slack event to a handler registered with `event(string)`', async () => { + app.event('app_mention', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyAppMentionEventMiddlewareArgs(), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack event to a handler registered with `event(RegExp)`', async () => { + app.event(/app_mention/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyAppMentionEventMiddlewareArgs(), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should throw if provided invalid message subtype event names', async () => { + app.event('app_mention', async () => {}); + app.event('message', async () => {}); + assert.throws(() => app.event('message.channels', async () => {})); + assert.throws(() => app.event(/message\..+/, async () => {})); + }); +}); diff --git a/test/unit/App/routing-message.spec.ts b/test/unit/App/routing-message.spec.ts new file mode 100644 index 000000000..85b00c0cb --- /dev/null +++ b/test/unit/App/routing-message.spec.ts @@ -0,0 +1,63 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyMessageEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + noopVoid, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App message() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: createFakeLogger(), + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a message event to a handler registered with `message(string)` if message contents match', async () => { + app.message('yo', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageEventMiddlewareArgs({ text: 'yo' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); + it('should route a message event to a handler registered with `message(RegExp)` if message contents match', async () => { + app.message(/hi/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageEventMiddlewareArgs({ text: 'hiya' }), + ack: noopVoid, + }); + sinon.assert.called(fakeHandler); + }); +}); diff --git a/test/unit/App/routing-options.spec.ts b/test/unit/App/routing-options.spec.ts new file mode 100644 index 000000000..af23355d9 --- /dev/null +++ b/test/unit/App/routing-options.spec.ts @@ -0,0 +1,76 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyBlockSuggestionsMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App options() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a block suggestion event to a handler registered with `options(string)` that matches the action ID', async () => { + app.options('my_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs({ action_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block suggestion event to a handler registered with `options(RegExp)` that matches the action ID', async () => { + app.options(/my_action/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs({ action_id: 'my_action' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block suggestion event to a handler registered with `options({block_id})` that matches the block ID', async () => { + app.options({ block_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs({ block_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a block suggestion event to a handler registered with `options({type:block_suggestion})`', async () => { + app.options({ type: 'block_suggestion' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyBlockSuggestionsMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); +}); diff --git a/test/unit/App/routing-shortcut.spec.ts b/test/unit/App/routing-shortcut.spec.ts new file mode 100644 index 000000000..8accb6aeb --- /dev/null +++ b/test/unit/App/routing-shortcut.spec.ts @@ -0,0 +1,88 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyMessageShortcutMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App shortcut() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should route a Slack shortcut event to a handler registered with `shortcut(string)` that matches the callback ID', async () => { + app.shortcut('my_callback_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_callback_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut(RegExp)` that matches the callback ID', async () => { + app.shortcut(/my_call/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_callback_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut({callback_id})` that matches the callback ID', async () => { + app.shortcut({ callback_id: 'my_callback_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_callback_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut({type})` that matches the type', async () => { + app.shortcut({ type: 'message_action' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a Slack shortcut event to a handler registered with `shortcut({type, callback_id})` that matches both the type and the callback_id', async () => { + app.shortcut({ type: 'message_action', callback_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyMessageShortcutMiddlewareArgs('my_id'), + }); + sinon.assert.called(fakeHandler); + }); + it('should throw if provided a constraint with unknown shortcut constraint keys', async () => { + // @ts-ignore providing known invalid shortcut constraint parameter + app.shortcut({ id: 'boom' }, fakeHandler); + sinon.assert.calledWithMatch(fakeLogger.error, 'unknown constraint keys'); + }); +}); diff --git a/test/unit/App/routing-view.spec.ts b/test/unit/App/routing-view.spec.ts new file mode 100644 index 000000000..983c6d2ac --- /dev/null +++ b/test/unit/App/routing-view.spec.ts @@ -0,0 +1,101 @@ +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyViewClosedMiddlewareArgs, + createDummyViewSubmissionMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App view() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should throw if provided a constraint with unknown view constraint keys', async () => { + // @ts-ignore providing known invalid view constraint parameter + app.view({ id: 'boom' }, fakeHandler); + sinon.assert.calledWithMatch(fakeLogger.error, 'unknown constraint keys'); + }); + describe('for view submission events', () => { + it('should route a view submission event to a handler registered with `view(string)` that matches the callback ID', async () => { + app.view('my_id', fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs({ callback_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view submission event to a handler registered with `view(RegExp)` that matches the callback ID', async () => { + app.view(/my_action/, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs({ callback_id: 'my_action' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view submission event to a handler registered with `view({callback_id})` that matches callback ID', async () => { + app.view({ callback_id: 'my_id' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs({ callback_id: 'my_id' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view submission event to a handler registered with `view({type:view_submission})`', async () => { + app.view({ type: 'view_submission' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewSubmissionMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + }); + + describe('for view closed events', () => { + it('should route a view closed event to a handler registered with `view({callback_id, type:view_closed})` that matches callback ID', async () => { + app.view({ callback_id: 'my_id', type: 'view_closed' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewClosedMiddlewareArgs({ callback_id: 'my_id', type: 'view_closed' }), + }); + sinon.assert.called(fakeHandler); + }); + it('should route a view closed event to a handler registered with `view({type:view_closed})`', async () => { + app.view({ type: 'view_closed' }, fakeHandler); + await fakeReceiver.sendEvent({ + ...createDummyViewClosedMiddlewareArgs(), + }); + sinon.assert.called(fakeHandler); + }); + }); +}); diff --git a/src/Assistant.spec.ts b/test/unit/Assistant.spec.ts similarity index 54% rename from src/Assistant.spec.ts rename to test/unit/Assistant.spec.ts index bd4cd0b36..bbd52f8f6 100644 --- a/src/Assistant.spec.ts +++ b/test/unit/Assistant.spec.ts @@ -1,28 +1,33 @@ -import 'mocha'; +import type { AssistantThreadStartedEvent } from '@slack/types'; +import type { WebClient } from '@slack/web-api'; import { assert } from 'chai'; -import sinon from 'sinon'; import rewiremock from 'rewiremock'; -import { WebClient } from '@slack/web-api'; +import sinon from 'sinon'; import { + type AllAssistantMiddlewareArgs, Assistant, - AssistantMiddlewareArgs, - AllAssistantMiddlewareArgs, - AssistantMiddleware, - AssistantConfig, - AssistantThreadStartedMiddlewareArgs, - AssistantThreadContextChangedMiddlewareArgs, - AssistantUserMessageMiddlewareArgs, -} from './Assistant'; -import { Override } from './test-helpers'; -import { AllMiddlewareArgs, AnyMiddlewareArgs, AssistantThreadStartedEvent, Middleware, SlackEventMiddlewareArgs } from './types'; -import { AssistantInitializationError, AssistantMissingPropertyError } from './errors'; -import { AssistantThreadContextStore, AssistantThreadContext } from './AssistantThreadContextStore'; - -async function importAssistant(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./Assistant'), overrides); + type AssistantConfig, + type AssistantMiddleware, + type AssistantMiddlewareArgs, +} from '../../src/Assistant'; +import type { AssistantThreadContext, AssistantThreadContextStore } from '../../src/AssistantThreadContextStore'; +import { AssistantInitializationError, AssistantMissingPropertyError } from '../../src/errors'; +import type { Middleware } from '../../src/types'; +import { + type Override, + createDummyAppMentionEventMiddlewareArgs, + createDummyAssistantThreadContextChangedEventMiddlewareArgs, + createDummyAssistantThreadStartedEventMiddlewareArgs, + createDummyAssistantUserMessageEventMiddlewareArgs, + createDummyMessageEventMiddlewareArgs, + wrapMiddleware, +} from './helpers'; + +async function importAssistant(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../src/Assistant'), overrides); } -const MOCK_FN = async () => { }; +const MOCK_FN = async () => {}; const MOCK_CONFIG_SINGLE = { threadStarted: MOCK_FN, @@ -65,7 +70,7 @@ describe('Assistant class', () => { // intentionally casting to AssistantConfig to trigger failure const badConfig = { - threadStarted: async () => { }, + threadStarted: async () => {}, } as unknown as AssistantConfig; const validationFn = () => validate(badConfig); @@ -78,9 +83,9 @@ describe('Assistant class', () => { // intentionally casting to AssistantConfig to trigger failure const badConfig = { - threadStarted: async () => { }, + threadStarted: async () => {}, threadContextChanged: {}, - userMessage: async () => { }, + userMessage: async () => {}, } as unknown as AssistantConfig; const validationFn = () => validate(badConfig); @@ -94,131 +99,85 @@ describe('Assistant class', () => { it('should call next if not an assistant event', async () => { const assistant = new Assistant(MOCK_CONFIG_SINGLE); const middleware = assistant.getMiddleware(); - const fakeMessageArgs = createGenericEvent() as unknown as AnyMiddlewareArgs & AllMiddlewareArgs; - fakeMessageArgs.payload.type = 'app_mention'; - - const fakeNext = sinon.spy(); - fakeMessageArgs.next = fakeNext; - + const fakeMessageArgs = wrapMiddleware(createDummyMessageEventMiddlewareArgs()); await middleware(fakeMessageArgs); - - assert(fakeNext.called); + sinon.assert.called(fakeMessageArgs.next); }); it('should not call next if a assistant event', async () => { const assistant = new Assistant(MOCK_CONFIG_SINGLE); const middleware = assistant.getMiddleware(); - const mockThreadStartedArgs = createMockThreadStartedEvent() as - unknown as AnyMiddlewareArgs & AllMiddlewareArgs; - - const fakeNext = sinon.spy(); - mockThreadStartedArgs.next = fakeNext; - + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); await middleware(mockThreadStartedArgs); - - assert(fakeNext.notCalled); + sinon.assert.notCalled(mockThreadStartedArgs.next); }); describe('isAssistantEvent', () => { it('should return true if recognized assistant event', async () => { - const mockThreadStartedArgs = createMockThreadStartedEvent() as - unknown as AnyMiddlewareArgs; - const mockThreadContextChangedArgs = createMockThreadContextChangedEvent() as - unknown as AnyMiddlewareArgs; - const mockUserMessageArgs = createMockUserMessageEvent() as - unknown as AnyMiddlewareArgs; + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const mockThreadContextChangedArgs = wrapMiddleware( + createDummyAssistantThreadContextChangedEventMiddlewareArgs(), + ); + const mockUserMessageArgs = wrapMiddleware(createDummyAssistantUserMessageEventMiddlewareArgs()); const { isAssistantEvent } = await importAssistant(); - const threadStartedIsAssistantEvent = isAssistantEvent(mockThreadStartedArgs); - const threadContextChangedIsAssistantEvent = isAssistantEvent(mockThreadContextChangedArgs); - const userMessageIsAssistantEvent = isAssistantEvent(mockUserMessageArgs); - - assert.isTrue(threadStartedIsAssistantEvent); - assert.isTrue(threadContextChangedIsAssistantEvent); - assert.isTrue(userMessageIsAssistantEvent); + assert(isAssistantEvent(mockThreadStartedArgs)); + assert(isAssistantEvent(mockThreadContextChangedArgs)); + assert(isAssistantEvent(mockUserMessageArgs)); }); it('should return false if not a recognized assistant event', async () => { - const fakeEventArgs = createGenericEvent() as unknown as SlackEventMiddlewareArgs; - fakeEventArgs.payload.type = 'function_executed'; - + const fakeMessageArgs = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs()); const { isAssistantEvent } = await importAssistant(); - const messageIsAssistantEvent = isAssistantEvent(fakeEventArgs as AnyMiddlewareArgs); - - assert.isFalse(messageIsAssistantEvent); + assert.isFalse(isAssistantEvent(fakeMessageArgs)); }); }); describe('matchesConstraints', () => { it('should return true if recognized assistant message', async () => { - const mockUserMessageArgs = createMockUserMessageEvent() as unknown as AssistantMiddlewareArgs; + const mockUserMessageArgs = wrapMiddleware(createDummyAssistantUserMessageEventMiddlewareArgs()); const { matchesConstraints } = await importAssistant(); - const eventMatchesConstraints = matchesConstraints(mockUserMessageArgs); - - assert.isTrue(eventMatchesConstraints); + assert.ok(matchesConstraints(mockUserMessageArgs)); }); it('should return false if not supported message subtype', async () => { - const mockAppMentionArgs = createGenericEvent() as unknown as any; - mockAppMentionArgs.payload.type = 'message'; - mockAppMentionArgs.payload.subtype = 'bot_message'; - + const fakeMessageArgs = wrapMiddleware(createDummyMessageEventMiddlewareArgs()); const { matchesConstraints } = await importAssistant(); - const eventMatchesConstraints = matchesConstraints(mockAppMentionArgs); - - assert.isFalse(eventMatchesConstraints); + // casting here as we intentionally are providing type-mismatched argument as a runtime test + assert.isFalse(matchesConstraints(fakeMessageArgs as unknown as AssistantMiddlewareArgs)); }); it('should return true if not message event', async () => { - const assistantThreadStartedArgs = createGenericEvent() as unknown as any; - assistantThreadStartedArgs.payload.type = 'assistant_thread_started'; - + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); const { matchesConstraints } = await importAssistant(); - const eventMatchesConstraints = matchesConstraints(assistantThreadStartedArgs); - - assert.isTrue(eventMatchesConstraints); + assert(matchesConstraints(mockThreadStartedArgs)); }); + }); - describe('isAssistantMessage', () => { - it('should return true if assistant message event', async () => { - const mockUserMessageArgs = createMockUserMessageEvent() as unknown as any; - const { isAssistantMessage } = await importAssistant(); - const userMessageIsAssistantEvent = isAssistantMessage(mockUserMessageArgs.payload); - - assert.isTrue(userMessageIsAssistantEvent); - }); - - it('should return false if not correct subtype', async () => { - const mockAppMentionArgs = createGenericEvent() as unknown as any; - mockAppMentionArgs.payload.type = 'message'; - mockAppMentionArgs.payload.subtype = 'app_mention'; - - const { isAssistantMessage } = await importAssistant(); - const userMessageIsAssistantEvent = isAssistantMessage(mockAppMentionArgs.payload); - - assert.isFalse(userMessageIsAssistantEvent); - }); - - it('should return false if thread_ts is missing', async () => { - const mockUnsupportedMessageArgs = createMockUserMessageEvent() as unknown as any; - delete mockUnsupportedMessageArgs.payload.thread_ts; - - const { isAssistantMessage } = await importAssistant(); - const userMessageIsAssistantEvent = isAssistantMessage(mockUnsupportedMessageArgs.payload); - - assert.isFalse(userMessageIsAssistantEvent); - }); + describe('isAssistantMessage', () => { + it('should return true if assistant message event', async () => { + const mockUserMessageArgs = wrapMiddleware(createDummyAssistantUserMessageEventMiddlewareArgs()); + const { isAssistantMessage } = await importAssistant(); + assert(isAssistantMessage(mockUserMessageArgs.payload)); + }); - it('should return false if channel_type is incorrect', async () => { - const mockUnsupportedMessageArgs = createMockUserMessageEvent() as unknown as any; - mockUnsupportedMessageArgs.payload.channel_type = 'mpim'; + it('should return false if not correct subtype', async () => { + const fakeMessageArgs = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ thread_ts: '1234.56' })); + const { isAssistantMessage } = await importAssistant(); + assert.isFalse(isAssistantMessage(fakeMessageArgs.payload)); + }); - const { isAssistantMessage } = await importAssistant(); - const userMessageIsAssistantEvent = isAssistantMessage(mockUnsupportedMessageArgs.payload); + it('should return false if thread_ts is missing', async () => { + const fakeMessageArgs = wrapMiddleware(createDummyMessageEventMiddlewareArgs()); + const { isAssistantMessage } = await importAssistant(); + assert.isFalse(isAssistantMessage(fakeMessageArgs.payload)); + }); - assert.isFalse(userMessageIsAssistantEvent); - }); + it('should return false if channel_type is incorrect', async () => { + const fakeMessageArgs = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ channel_type: 'mpim' })); + const { isAssistantMessage } = await importAssistant(); + assert.isFalse(isAssistantMessage(fakeMessageArgs.payload)); }); }); }); @@ -226,12 +185,11 @@ describe('Assistant class', () => { describe('processEvent', () => { describe('enrichAssistantArgs', () => { it('should remove next() from all original event args', async () => { - const mockThreadStartedArgs = createMockThreadStartedEvent() as - unknown as AssistantThreadStartedMiddlewareArgs & AllMiddlewareArgs; - const mockThreadContextChangedArgs = createMockThreadContextChangedEvent() as - unknown as AssistantThreadContextChangedMiddlewareArgs & AllMiddlewareArgs; - const mockUserMessageArgs = createMockUserMessageEvent() as - unknown as AssistantUserMessageMiddlewareArgs & AllMiddlewareArgs; + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const mockThreadContextChangedArgs = wrapMiddleware( + createDummyAssistantThreadContextChangedEventMiddlewareArgs(), + ); + const mockUserMessageArgs = wrapMiddleware(createDummyAssistantUserMessageEventMiddlewareArgs()); const mockThreadContextStore = createMockThreadContextStore(); const { enrichAssistantArgs } = await importAssistant(); @@ -246,10 +204,11 @@ describe('Assistant class', () => { }); it('should augment assistant_thread_started args with utilities', async () => { - const mockArgs = createMockThreadStartedEvent(); + const { payload } = createDummyAssistantThreadStartedEventMiddlewareArgs(); const mockThreadContextStore = createMockThreadContextStore(); const { enrichAssistantArgs } = await importAssistant(); - const assistantArgs = enrichAssistantArgs(mockThreadContextStore, mockArgs as any); + // TODO: enrichAssistantArgs likely needs a different argument type, as AssistantMiddlewareArgs type already has the assistant utility enrichments present. + const assistantArgs = enrichAssistantArgs(mockThreadContextStore, { payload } as AllAssistantMiddlewareArgs); assert.exists(assistantArgs.say); assert.exists(assistantArgs.setStatus); @@ -258,10 +217,11 @@ describe('Assistant class', () => { }); it('should augment assistant_thread_context_changed args with utilities', async () => { - const mockArgs = createMockThreadContextChangedEvent(); + const { payload } = createDummyAssistantThreadContextChangedEventMiddlewareArgs(); const mockThreadContextStore = createMockThreadContextStore(); const { enrichAssistantArgs } = await importAssistant(); - const assistantArgs = enrichAssistantArgs(mockThreadContextStore, mockArgs as any); + // TODO: enrichAssistantArgs likely needs a different argument type, as AssistantMiddlewareArgs type already has the assistant utility enrichments present. + const assistantArgs = enrichAssistantArgs(mockThreadContextStore, { payload } as AllAssistantMiddlewareArgs); assert.exists(assistantArgs.say); assert.exists(assistantArgs.setStatus); @@ -270,10 +230,11 @@ describe('Assistant class', () => { }); it('should augment message args with utilities', async () => { - const mockArgs = createMockUserMessageEvent(); + const { payload } = createDummyAssistantUserMessageEventMiddlewareArgs(); const mockThreadContextStore = createMockThreadContextStore(); const { enrichAssistantArgs } = await importAssistant(); - const assistantArgs = enrichAssistantArgs(mockThreadContextStore, mockArgs as any); + // TODO: enrichAssistantArgs likely needs a different argument type, as AssistantMiddlewareArgs type already has the assistant utility enrichments present. + const assistantArgs = enrichAssistantArgs(mockThreadContextStore, { payload } as AllAssistantMiddlewareArgs); assert.exists(assistantArgs.say); assert.exists(assistantArgs.setStatus); @@ -283,8 +244,8 @@ describe('Assistant class', () => { describe('extractThreadInfo', () => { it('should return expected channelId, threadTs, and context for `assistant_thread_started` event', async () => { - const mockThreadStartedEvent = createMockThreadStartedEvent() as unknown as AssistantThreadStartedMiddlewareArgs; // eslint-disable-line max-len - const { payload } = mockThreadStartedEvent; + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const { payload } = mockThreadStartedArgs; const { extractThreadInfo } = await importAssistant(); const { channelId, threadTs, context } = extractThreadInfo(payload); @@ -294,8 +255,10 @@ describe('Assistant class', () => { }); it('should return expected channelId, threadTs, and context for `assistant_thread_context_changed` event', async () => { - const mockThreadChangedEvent = createMockThreadContextChangedEvent() as unknown as AssistantThreadContextChangedMiddlewareArgs; // eslint-disable-line max-len - const { payload } = mockThreadChangedEvent; + const mockThreadContextChangedArgs = wrapMiddleware( + createDummyAssistantThreadContextChangedEventMiddlewareArgs(), + ); + const { payload } = mockThreadContextChangedArgs; const { extractThreadInfo } = await importAssistant(); const { channelId, threadTs, context } = extractThreadInfo(payload); @@ -305,18 +268,19 @@ describe('Assistant class', () => { }); it('should return expected channelId and threadTs for `message` event', async () => { - const mockUserMessageEvent = createMockUserMessageEvent(); - const { payload } = mockUserMessageEvent as any; + const mockUserMessageArgs = wrapMiddleware(createDummyAssistantUserMessageEventMiddlewareArgs()); + const { payload } = mockUserMessageArgs; const { extractThreadInfo } = await importAssistant(); const { channelId, threadTs, context } = extractThreadInfo(payload); assert.equal(payload.channel, channelId); + // @ts-expect-error TODO: AssistantUserMessageMiddlewareArgs extends from too broad of a message event type, which contains types that explicitly DO NOT have a thread_ts. this is at odds with the expectation around assistant user message events. assert.equal(payload.thread_ts, threadTs); assert.isEmpty(context); }); it('should throw error if `channel_id` or `thread_ts` are missing', async () => { - const { payload } = createMockThreadStartedEvent() as unknown as AssistantThreadStartedMiddlewareArgs; // eslint-disable-line max-len + const { payload } = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); payload.assistant_thread.channel_id = ''; const { extractThreadInfo } = await importAssistant(); @@ -328,7 +292,7 @@ describe('Assistant class', () => { describe('assistant args/utilities', () => { it('say should call chat.postMessage', async () => { - const mockThreadStartedArgs = createMockThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); const fakeClient = { chat: { postMessage: sinon.spy() } }; mockThreadStartedArgs.client = fakeClient as unknown as WebClient; @@ -339,11 +303,11 @@ describe('Assistant class', () => { await threadStartedArgs.say('Say called!'); - assert(fakeClient.chat.postMessage.called); + sinon.assert.called(fakeClient.chat.postMessage); }); it('setStatus should call assistant.threads.setStatus', async () => { - const mockThreadStartedArgs = createMockThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); const fakeClient = { assistant: { threads: { setStatus: sinon.spy() } } }; mockThreadStartedArgs.client = fakeClient as unknown as WebClient; @@ -354,11 +318,11 @@ describe('Assistant class', () => { await threadStartedArgs.setStatus('Status set!'); - assert(fakeClient.assistant.threads.setStatus.called); + sinon.assert.called(fakeClient.assistant.threads.setStatus); }); it('setSuggestedPrompts should call assistant.threads.setSuggestedPrompts', async () => { - const mockThreadStartedArgs = createMockThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); const fakeClient = { assistant: { threads: { setSuggestedPrompts: sinon.spy() } } }; mockThreadStartedArgs.client = fakeClient as unknown as WebClient; @@ -369,11 +333,11 @@ describe('Assistant class', () => { await threadStartedArgs.setSuggestedPrompts({ prompts: [{ title: '', message: '' }] }); - assert(fakeClient.assistant.threads.setSuggestedPrompts.called); + sinon.assert.called(fakeClient.assistant.threads.setSuggestedPrompts); }); it('setTitle should call assistant.threads.setTitle', async () => { - const mockThreadStartedArgs = createMockThreadStartedEvent() as unknown as AssistantMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); const fakeClient = { assistant: { threads: { setTitle: sinon.spy() } } }; mockThreadStartedArgs.client = fakeClient as unknown as WebClient; @@ -384,23 +348,25 @@ describe('Assistant class', () => { await threadStartedArgs.setTitle('Title set!'); - assert(fakeClient.assistant.threads.setTitle.called); + sinon.assert.called(fakeClient.assistant.threads.setTitle); }); }); }); describe('processAssistantMiddleware', () => { it('should call each callback in user-provided middleware', async () => { - const { ...mockArgs } = createMockThreadContextChangedEvent() as unknown as AllAssistantMiddlewareArgs; + const mockThreadContextChangedArgs = wrapMiddleware( + createDummyAssistantThreadContextChangedEventMiddlewareArgs(), + ); const { processAssistantMiddleware } = await importAssistant(); const fn1 = sinon.spy((async ({ next: continuation }) => { await continuation(); }) as Middleware); - const fn2 = sinon.spy(async () => { }); + const fn2 = sinon.spy(async () => {}); const fakeMiddleware = [fn1, fn2] as AssistantMiddleware; - await processAssistantMiddleware(mockArgs, fakeMiddleware); + await processAssistantMiddleware(mockThreadContextChangedArgs, fakeMiddleware); assert(fn1.called); assert(fn2.called); @@ -409,84 +375,11 @@ describe('Assistant class', () => { }); }); -function createMockThreadStartedEvent() { - return { - payload: { - type: 'assistant_thread_started', - assistant_thread: { - user_id: '', - context: { - channel_id: '', - team_id: '', - enterprise_id: '', - }, - channel_id: 'D01234567AR', - thread_ts: '1234567890.123456', - }, - event_ts: '', - }, - context: {}, - }; -} - -function createMockThreadContextChangedEvent() { - return { - payload: { - type: 'assistant_thread_context_changed', - assistant_thread: { - user_id: '', - context: { - channel_id: '', - team_id: '', - enterprise_id: '', - }, - channel_id: 'D01234567AR', - thread_ts: '1234567890.123456', - }, - event_ts: '', - }, - context: {}, - }; -} - -function createMockUserMessageEvent() { - return { - payload: { - user: '', - type: 'message', - ts: '', - text: 'test', - team: '', - user_team: '', - source_team: '', - user_profile: {}, - thread_ts: '1234567890.123456', - parent_user_id: '', - blocks: [], - channel: 'D01234567AR', - event_ts: '', - channel_type: 'im', - }, - context: {}, - }; -} - -function createGenericEvent() { - return { - payload: { - type: '', - }, - context: {}, - }; -} - function createMockThreadContextStore(): AssistantThreadContextStore { return { async get(_: AllAssistantMiddlewareArgs): Promise { return {}; }, - // eslint-disable-next-line @typescript-eslint/no-empty-function - async save(_: AllAssistantMiddlewareArgs): Promise { - }, + async save(_: AllAssistantMiddlewareArgs): Promise {}, }; } diff --git a/test/unit/AssistantThreadContextStore.spec.ts b/test/unit/AssistantThreadContextStore.spec.ts new file mode 100644 index 000000000..d1453c332 --- /dev/null +++ b/test/unit/AssistantThreadContextStore.spec.ts @@ -0,0 +1,158 @@ +import type { WebClient } from '@slack/web-api'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import { extractThreadInfo } from '../../src/Assistant'; +import { DefaultThreadContextStore } from '../../src/AssistantThreadContextStore'; +import { createDummyAssistantThreadStartedEventMiddlewareArgs, wrapMiddleware } from './helpers'; + +describe('DefaultThreadContextStore class', () => { + describe('get', () => { + it('should retrieve message metadata if context not already saved to instance', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const botUserId = 'U1234'; + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs(), { + botUserId, + isEnterpriseInstall: false, + }); + const mockThreadContext = { channel_id: '123', thread_ts: '123', enterprise_id: null }; + const fakeClient = { + conversations: { + replies: sinon.fake.returns({ + messages: [ + { + user: botUserId, + ts: '12345', + metadata: { event_payload: mockThreadContext }, + }, + ], + }), + }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + const threadContext = await mockContextStore.get(mockThreadStartedArgs); + + sinon.assert.called(fakeClient.conversations.replies); + assert.equal(threadContext, mockThreadContext); + }); + + it('should return an empty object if no message history exists', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const fakeClient = { conversations: { replies: sinon.fake.returns([]) } }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + const threadContext = await mockContextStore.get(mockThreadStartedArgs); + + assert.isEmpty(threadContext); + }); + + it('should return an empty object if no message metadata exists', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const fakeClient = { + conversations: { + replies: sinon.fake.returns({ + messages: [ + { + user: 'U12345', + ts: '12345', + metadata: {}, + }, + ], + }), + }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + const threadContext = await mockContextStore.get(mockThreadStartedArgs); + + assert.isEmpty(threadContext); + }); + + it('should retrieve instance context if it has been saved previously', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const fakeClient = { + conversations: { replies: sinon.fake.returns({ messages: [{ user: 'U12345', ts: '12345' }] }) }, + chat: { update: sinon.fake() }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + + await mockContextStore.save(mockThreadStartedArgs); + const threadContext = await mockContextStore.get(mockThreadStartedArgs); + + sinon.assert.calledOnce(fakeClient.conversations.replies); + assert.equal(threadContext, mockThreadStartedArgs.payload.assistant_thread.context); + }); + }); + + describe('save', () => { + it('should update instance context with threadContext', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const fakeClient = { + conversations: { replies: sinon.fake.returns({ messages: [] }) }, + chat: { update: sinon.fake() }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + + await mockContextStore.save(mockThreadStartedArgs); + const instanceContext = await mockContextStore.get(mockThreadStartedArgs); + + sinon.assert.called(fakeClient.conversations.replies); + assert.deepEqual(instanceContext, mockThreadStartedArgs.payload.assistant_thread.context); + }); + + it('should retrieve message history', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const fakeClient = { + conversations: { replies: sinon.fake.returns({}) }, + chat: { update: sinon.fake() }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + + await mockContextStore.save(mockThreadStartedArgs); + sinon.assert.calledOnce(fakeClient.conversations.replies); + }); + + it('should return early if no message history exists', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs()); + const fakeClient = { + conversations: { replies: sinon.fake.returns({}) }, + chat: { update: sinon.fake() }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + + await mockContextStore.save(mockThreadStartedArgs); + sinon.assert.notCalled(fakeClient.chat.update); + }); + + it('should update first bot message metadata with threadContext', async () => { + const mockContextStore = new DefaultThreadContextStore(); + const botUserId = 'U1234'; + const mockThreadStartedArgs = wrapMiddleware(createDummyAssistantThreadStartedEventMiddlewareArgs(), { + botUserId, + isEnterpriseInstall: false, + }); + const fakeClient = { + conversations: { replies: sinon.fake.returns({ messages: [{ user: botUserId, ts: '12345', text: 'foo' }] }) }, + chat: { update: sinon.fake() }, + }; + mockThreadStartedArgs.client = fakeClient as unknown as WebClient; + const { channelId, context } = extractThreadInfo(mockThreadStartedArgs.payload); + const mockParams = { + channel: channelId, + ts: '12345', + text: 'foo', + blocks: [], + metadata: { + event_type: 'assistant_thread_context', + event_payload: context, + }, + }; + + await mockContextStore.save(mockThreadStartedArgs); + sinon.assert.calledWith(fakeClient.chat.update, mockParams); + }); + }); +}); diff --git a/src/CustomFunction.spec.ts b/test/unit/CustomFunction.spec.ts similarity index 89% rename from src/CustomFunction.spec.ts rename to test/unit/CustomFunction.spec.ts index 24dc00059..88ca9a0fb 100644 --- a/src/CustomFunction.spec.ts +++ b/test/unit/CustomFunction.spec.ts @@ -1,21 +1,20 @@ -import 'mocha'; +import { WebClient } from '@slack/web-api'; import { assert } from 'chai'; -import sinon from 'sinon'; import rewiremock from 'rewiremock'; -import { WebClient } from '@slack/web-api'; +import sinon from 'sinon'; import { + type AllCustomFunctionMiddlewareArgs, CustomFunction, - SlackCustomFunctionMiddlewareArgs, - AllCustomFunctionMiddlewareArgs, - CustomFunctionMiddleware, - CustomFunctionExecuteMiddlewareArgs, -} from './CustomFunction'; -import { createFakeLogger, Override } from './test-helpers'; -import { AllMiddlewareArgs, Middleware } from './types'; -import { CustomFunctionInitializationError } from './errors'; - -async function importCustomFunction(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./CustomFunction'), overrides); + type CustomFunctionExecuteMiddlewareArgs, + type CustomFunctionMiddleware, + type SlackCustomFunctionMiddlewareArgs, +} from '../../src/CustomFunction'; +import { CustomFunctionInitializationError } from '../../src/errors'; +import type { AllMiddlewareArgs, Middleware } from '../../src/types'; +import { type Override, createFakeLogger } from './helpers'; + +async function importCustomFunction(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../src/CustomFunction'), overrides); } const MOCK_FN = async () => {}; @@ -68,8 +67,7 @@ describe('CustomFunction class', () => { it('should call next if not a function executed event', async () => { const fn = new CustomFunction('test_view_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); const middleware = fn.getMiddleware(); - const fakeViewArgs = createFakeViewEvent() as unknown as - SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + const fakeViewArgs = createFakeViewEvent() as unknown as SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; const fakeNext = sinon.spy(); fakeViewArgs.next = fakeNext; @@ -107,10 +105,7 @@ describe('CustomFunction class', () => { const { validate } = await importCustomFunction(); // intentionally casting to CustomFunctionMiddleware to trigger failure - const badMiddleware = [ - async () => {}, - 'not-a-function', - ] as unknown as CustomFunctionMiddleware; + const badMiddleware = [async () => {}, 'not-a-function'] as unknown as CustomFunctionMiddleware; const validationFn = () => validate('callback_id', badMiddleware); const expectedMsg = 'All CustomFunction middleware must be functions'; @@ -167,7 +162,10 @@ describe('CustomFunction class', () => { it('complete should call functions.completeSuccess', async () => { const client = new WebClient('sometoken'); const completeMock = sinon.stub(client.functions, 'completeSuccess').resolves(); - const complete = CustomFunction.createFunctionComplete({ isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, client); + const complete = CustomFunction.createFunctionComplete( + { isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, + client, + ); await complete(); assert(completeMock.called, 'client.functions.completeSuccess not called!'); }); @@ -183,7 +181,10 @@ describe('CustomFunction class', () => { it('fail should call functions.completeError', async () => { const client = new WebClient('sometoken'); const completeMock = sinon.stub(client.functions, 'completeError').resolves(); - const complete = CustomFunction.createFunctionFail({ isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, client); + const complete = CustomFunction.createFunctionFail( + { isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, + client, + ); await complete({ error: 'boom' }); assert(completeMock.called, 'client.functions.completeError not called!'); }); @@ -213,8 +214,7 @@ describe('CustomFunction class', () => { const fn1 = sinon.spy((async ({ next: continuation }) => { await continuation(); }) as Middleware); - const fn2 = sinon.spy(async () => { - }); + const fn2 = sinon.spy(async () => {}); const fakeMiddleware = [fn1, fn2] as CustomFunctionMiddleware; await processFunctionMiddleware(fakeArgs, fakeMiddleware); @@ -272,14 +272,12 @@ function createFakeFunctionExecutedEvent(callbackId?: string): AllCustomFunction fail: () => Promise.resolve({ ok: true }), inputs, logger: createFakeLogger(), - message: undefined, next: () => Promise.resolve(), payload: { function: func, inputs: { message: 'test123', recipient: 'U012345' }, ...base, }, - say: undefined, }; } diff --git a/src/WorkflowStep.spec.ts b/test/unit/WorkflowStep.spec.ts similarity index 86% rename from src/WorkflowStep.spec.ts rename to test/unit/WorkflowStep.spec.ts index df9440f24..700a89a70 100644 --- a/src/WorkflowStep.spec.ts +++ b/test/unit/WorkflowStep.spec.ts @@ -1,38 +1,35 @@ -import 'mocha'; +import type { WebClient } from '@slack/web-api'; import { assert } from 'chai'; -import sinon from 'sinon'; import rewiremock from 'rewiremock'; -import { WebClient } from '@slack/web-api'; +import sinon from 'sinon'; import { + type AllWorkflowStepMiddlewareArgs, + type SlackWorkflowStepMiddlewareArgs, WorkflowStep, - SlackWorkflowStepMiddlewareArgs, - AllWorkflowStepMiddlewareArgs, - WorkflowStepMiddleware, - WorkflowStepConfig, - WorkflowStepEditMiddlewareArgs, - WorkflowStepSaveMiddlewareArgs, - WorkflowStepExecuteMiddlewareArgs, -} from './WorkflowStep'; -import { Override } from './test-helpers'; -import { AllMiddlewareArgs, AnyMiddlewareArgs, WorkflowStepEdit, Middleware } from './types'; -import { WorkflowStepInitializationError } from './errors'; - -async function importWorkflowStep(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./WorkflowStep'), overrides); + type WorkflowStepConfig, + type WorkflowStepEditMiddlewareArgs, + type WorkflowStepExecuteMiddlewareArgs, + type WorkflowStepMiddleware, + type WorkflowStepSaveMiddlewareArgs, +} from '../../src/WorkflowStep'; +import { WorkflowStepInitializationError } from '../../src/errors'; +import type { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware, WorkflowStepEdit } from '../../src/types'; +import { type Override, noopVoid } from './helpers'; + +async function importWorkflowStep(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../src/WorkflowStep'), overrides); } -const MOCK_FN = async () => {}; - const MOCK_CONFIG_SINGLE = { - edit: MOCK_FN, - save: MOCK_FN, - execute: MOCK_FN, + edit: noopVoid, + save: noopVoid, + execute: noopVoid, }; const MOCK_CONFIG_MULTIPLE = { - edit: [MOCK_FN, MOCK_FN], - save: [MOCK_FN], - execute: [MOCK_FN, MOCK_FN, MOCK_FN], + edit: [noopVoid, noopVoid], + save: [noopVoid], + execute: [noopVoid, noopVoid, noopVoid], }; describe('WorkflowStep class', () => { @@ -145,8 +142,8 @@ describe('WorkflowStep class', () => { 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 fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; const { isStepEvent } = await importWorkflowStep(); @@ -174,7 +171,8 @@ describe('WorkflowStep class', () => { 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; // eslint-disable-line max-len + const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & + AllMiddlewareArgs; const { prepareStepArgs } = await importWorkflowStep(); @@ -191,31 +189,35 @@ describe('WorkflowStep class', () => { const fakeArgs = createFakeStepEditAction(); const { prepareStepArgs } = await importWorkflowStep(); // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs) as AllWorkflowStepMiddlewareArgs; + const stepArgs = prepareStepArgs(fakeArgs as AllWorkflowStepMiddlewareArgs); assert.exists(stepArgs.step); - assert.exists(stepArgs.configure); + assert.property(stepArgs, 'configure'); }); it('should augment view_submission with step and update()', async () => { const fakeArgs = createFakeStepSaveEvent(); const { prepareStepArgs } = await importWorkflowStep(); // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs) as AllWorkflowStepMiddlewareArgs; + const stepArgs = prepareStepArgs( + fakeArgs as unknown as AllWorkflowStepMiddlewareArgs, + ); assert.exists(stepArgs.step); - assert.exists(stepArgs.update); + assert.property(stepArgs, 'update'); }); it('should augment workflow_step_execute with step, complete() and fail()', async () => { const fakeArgs = createFakeStepExecuteEvent(); const { prepareStepArgs } = await importWorkflowStep(); // casting to returned type because prepareStepArgs isn't built to do so - const stepArgs = prepareStepArgs(fakeArgs) as AllWorkflowStepMiddlewareArgs; + const stepArgs = prepareStepArgs( + fakeArgs as unknown as AllWorkflowStepMiddlewareArgs, + ); assert.exists(stepArgs.step); - assert.exists(stepArgs.complete); - assert.exists(stepArgs.fail); + assert.property(stepArgs, 'complete'); + assert.property(stepArgs, 'fail'); }); }); @@ -255,7 +257,8 @@ describe('WorkflowStep class', () => { }); it('complete should call workflows.stepCompleted', async () => { - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + 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; @@ -272,7 +275,8 @@ describe('WorkflowStep class', () => { }); it('fail should call workflows.stepFailed', async () => { - const fakeExecuteArgs = createFakeStepExecuteEvent() as unknown as SlackWorkflowStepMiddlewareArgs & AllMiddlewareArgs; // eslint-disable-line max-len + 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; @@ -308,6 +312,9 @@ describe('WorkflowStep class', () => { }); }); +// 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: { diff --git a/src/conversation-store.spec.ts b/test/unit/conversation-store.spec.ts similarity index 81% rename from src/conversation-store.spec.ts rename to test/unit/conversation-store.spec.ts index 624cb354f..1f7fe6d93 100644 --- a/src/conversation-store.spec.ts +++ b/test/unit/conversation-store.spec.ts @@ -1,13 +1,51 @@ -import 'mocha'; +import type { Logger } from '@slack/logger'; +import type { WebClient } from '@slack/web-api'; import { assert, AssertionError } from 'chai'; -import sinon, { SinonSpy } from 'sinon'; import rewiremock from 'rewiremock'; -import { Logger } from '@slack/logger'; -import { WebClient } from '@slack/web-api'; -import { Override, createFakeLogger, delay } from './test-helpers'; -import { ConversationStore } from './conversation-store'; -import { AnyMiddlewareArgs, NextFn, Context } from './types'; +import sinon, { type SinonSpy } from 'sinon'; +import type { AnyMiddlewareArgs, Context, NextFn } from '../../src/types'; +import { type Override, createFakeLogger, delay } from './helpers'; +/* Testing Harness */ + +type MiddlewareArgs = AnyMiddlewareArgs & { + next: NextFn; + context: Context; + logger: Logger; + client: WebClient; +}; + +interface DummyContext { + conversation?: ConversationState; + updateConversation?: (c: ConversationState, expiresAt?: number) => Promise; +} + +// Loading the system under test using overrides +async function importConversationStore( + overrides: Override = {}, +): Promise { + return rewiremock.module(() => import('../../src/conversation-store'), overrides); +} + +// Composable overrides +function withGetTypeAndConversation(spy: SinonSpy): Override { + return { + './helpers': { + getTypeAndConversation: spy, + }, + }; +} + +// Fakes +function createFakeStore( + getSpy: SinonSpy = sinon.fake.resolves(undefined), + setSpy: SinonSpy = sinon.fake.resolves({}), +) { + return { + set: setSpy, + get: getSpy, + }; +} describe('conversationContext middleware', () => { it('should forward events that have no conversation ID', async () => { // Arrange @@ -198,7 +236,7 @@ describe('MemoryStore', () => { try { await store.get('CONVERSATION_ID'); assert.fail(); - } catch (error: any) { + } catch (error) { // Assert assert.instanceOf(error, Error); assert.notInstanceOf(error, AssertionError); @@ -219,7 +257,7 @@ describe('MemoryStore', () => { try { await store.get(dummyConversationId); assert.fail(); - } catch (error: any) { + } catch (error) { // Assert assert.instanceOf(error, Error); assert.notInstanceOf(error, AssertionError); @@ -227,57 +265,3 @@ describe('MemoryStore', () => { }); }); }); - -/* Testing Harness */ - -type MiddlewareArgs = AnyMiddlewareArgs & { - next: NextFn; - context: Context; - logger: Logger; - client: WebClient; -}; - -interface DummyContext { - conversation?: ConversationState; - updateConversation?: (c: ConversationState, expiresAt?: number) => Promise; -} - -// Loading the system under test using overrides -async function importConversationStore(overrides: Override = {}): Promise { - return rewiremock.module(() => import('./conversation-store'), overrides); -} - -// Composable overrides -function withGetTypeAndConversation(spy: SinonSpy): Override { - return { - './helpers': { - getTypeAndConversation: spy, - }, - }; -} - -// Fakes -interface FakeStore extends ConversationStore { - set: SinonSpy, ReturnType>; - get: SinonSpy, ReturnType>; -} - -function createFakeStore( - getSpy: SinonSpy = sinon.fake.resolves(undefined), - setSpy: SinonSpy = sinon.fake.resolves({}), -): FakeStore { - return { - // NOTE (Nov 2019): We had to convert to 'unknown' first due to the following error: - // src/conversation-store.spec.ts:223:10 - error TS2352: Conversion of type 'SinonSpy' to - // type 'SinonSpy<[string, any, (number | undefined)?], Promise>' may be a mistake because neither type - // sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first. - // Types of property 'firstCall' are incompatible. - // Type 'SinonSpyCall' is not comparable to type 'SinonSpyCall<[string, any, (number | undefined)?], - // Promise>'. - // Type 'any[]' is not comparable to type '[string, any, (number | undefined)?]'. - // 223 set: setSpy as SinonSpy, ReturnType>, - // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - set: setSpy as unknown as SinonSpy, ReturnType>, - get: getSpy as unknown as SinonSpy, ReturnType>, - }; -} diff --git a/src/errors.spec.ts b/test/unit/errors.spec.ts similarity index 91% rename from src/errors.spec.ts rename to test/unit/errors.spec.ts index 7667e26b3..ec907eaa2 100644 --- a/src/errors.spec.ts +++ b/test/unit/errors.spec.ts @@ -1,15 +1,15 @@ import { assert } from 'chai'; import { - asCodedError, - ErrorCode, - CodedError, AppInitializationError, AuthorizationError, + type CodedError, ContextMissingPropertyError, + ErrorCode, ReceiverAuthenticityError, ReceiverMultipleAckError, UnknownError, -} from './errors'; + asCodedError, +} from '../../src/errors'; describe('Errors', () => { it('has errors matching codes', () => { @@ -22,9 +22,9 @@ describe('Errors', () => { [ErrorCode.UnknownError]: new UnknownError(new Error('It errored')), }; - Object.entries(errorMap).forEach(([code, error]) => { + for (const [code, error] of Object.entries(errorMap)) { assert.equal((error as CodedError).code, code); - }); + } }); it('wraps non-coded errors', () => { diff --git a/src/helpers.spec.ts b/test/unit/helpers.spec.ts similarity index 86% rename from src/helpers.spec.ts rename to test/unit/helpers.spec.ts index 9daab84ca..fefc4ea42 100644 --- a/src/helpers.spec.ts +++ b/test/unit/helpers.spec.ts @@ -1,7 +1,11 @@ -import 'mocha'; import { assert } from 'chai'; -import { isBodyWithTypeEnterpriseInstall, getTypeAndConversation, IncomingEventType, isEventTypeToSkipAuthorize } from './helpers'; -import { AnyMiddlewareArgs, ReceiverEvent, SlackEventMiddlewareArgs } from './types'; +import { + IncomingEventType, + getTypeAndConversation, + isBodyWithTypeEnterpriseInstall, + isEventTypeToSkipAuthorize, +} from '../../src/helpers'; +import type { AnyMiddlewareArgs, ReceiverEvent, SlackEventMiddlewareArgs } from '../../src/types'; describe('Helpers', () => { describe('getTypeAndConversation()', () => { @@ -43,7 +47,7 @@ describe('Helpers', () => { // Arrange const conversationId = 'CONVERSATION_ID'; const dummyActionBodies = createFakeOptions(conversationId); - dummyActionBodies.forEach((option) => { + for (const option of dummyActionBodies) { it(`should find Option type for ${option.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(option); @@ -51,13 +55,13 @@ describe('Helpers', () => { assert(typeAndConversation.type === IncomingEventType.Options); assert(typeAndConversation.conversationId === conversationId); }); - }); + } }); describe('action types', () => { // Arrange const conversationId = 'CONVERSATION_ID'; const dummyActionBodies = createFakeActions(conversationId); - dummyActionBodies.forEach((action) => { + for (const action of dummyActionBodies) { it(`should find Action type for ${action.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(action); @@ -65,13 +69,13 @@ describe('Helpers', () => { assert(typeAndConversation.type === IncomingEventType.Action); assert(typeAndConversation.conversationId === conversationId); }); - }); + } }); describe('shortcut types', () => { // Arrange const conversationId = 'CONVERSATION_ID'; const dummyShortcutBodies = createFakeShortcuts(conversationId); - dummyShortcutBodies.forEach((shortcut) => { + for (const shortcut of dummyShortcutBodies) { it(`should find Shortcut type for ${shortcut.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(shortcut); @@ -81,19 +85,19 @@ describe('Helpers', () => { assert(typeAndConversation.conversationId === conversationId); } }); - }); + } }); describe('view types', () => { // Arrange const dummyViewBodies = createFakeViews(); - dummyViewBodies.forEach((viewBody) => { + for (const viewBody of dummyViewBodies) { it(`should find Action type for ${viewBody.type}`, () => { // Act const typeAndConversation = getTypeAndConversation(viewBody); // Assert assert(typeAndConversation.type === IncomingEventType.ViewAction); }); - }); + } }); describe('invalid events', () => { // Arrange @@ -128,13 +132,15 @@ describe('Helpers', () => { channel: '', event_ts: '', }, - authorizations: [{ - enterprise_id: '', - is_bot: true, - team_id: '', - user_id: '', - is_enterprise_install: true, - }], + authorizations: [ + { + enterprise_id: '', + is_bot: true, + team_id: '', + user_id: '', + is_enterprise_install: true, + }, + ], }; it('should resolve the is_enterprise_install field', () => { @@ -197,7 +203,7 @@ describe('Helpers', () => { describe('receiver events that can be skipped', () => { it('should return truthy when event can be skipped', () => { // Arrange - const dummyEventBody = { ack: async () => { }, body: { event: { type: 'app_uninstalled' } } } as ReceiverEvent; + const dummyEventBody = { ack: async () => {}, body: { event: { type: 'app_uninstalled' } } } as ReceiverEvent; // Act const isEnterpriseInstall = isEventTypeToSkipAuthorize(dummyEventBody); // Assert @@ -206,7 +212,7 @@ describe('Helpers', () => { it('should return falsy when event can not be skipped', () => { // Arrange - const dummyEventBody = { ack: async () => { }, body: { event: { type: '' } } } as ReceiverEvent; + const dummyEventBody = { ack: async () => {}, body: { event: { type: '' } } } as ReceiverEvent; // Act const isEnterpriseInstall = isEventTypeToSkipAuthorize(dummyEventBody); // Assert @@ -215,7 +221,7 @@ describe('Helpers', () => { it('should return falsy when event is invalid', () => { // Arrange - const dummyEventBody = { ack: async () => { }, body: {} } as ReceiverEvent; + const dummyEventBody = { ack: async () => {}, body: {} } as ReceiverEvent; // Act const isEnterpriseInstall = isEventTypeToSkipAuthorize(dummyEventBody); // Assert @@ -225,6 +231,7 @@ describe('Helpers', () => { }); }); +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeActions(conversationId: string): any[] { return [ // Body for a dialog submission @@ -255,6 +262,7 @@ function createFakeActions(conversationId: string): any[] { ]; } +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeShortcuts(conversationId: string): any[] { return [ // Body for a message shortcut @@ -269,6 +277,7 @@ function createFakeShortcuts(conversationId: string): any[] { ]; } +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeOptions(conversationId: string): any[] { return [ // Body for an options request in an interactive message @@ -291,6 +300,7 @@ function createFakeOptions(conversationId: string): any[] { ]; } +// biome-ignore lint/suspicious/noExplicitAny: test utilities can return anything function createFakeViews(): any[] { return [ // Body for a view_submission event diff --git a/test/unit/helpers/app.ts b/test/unit/helpers/app.ts new file mode 100644 index 000000000..0c11335db --- /dev/null +++ b/test/unit/helpers/app.ts @@ -0,0 +1,138 @@ +import type { AuthTestResponse, WebClientOptions } from '@slack/web-api'; +import rewiremock from 'rewiremock'; +import sinon, { type SinonSpy } from 'sinon'; + +/* + * Contains test helpers related to importing, mocking and overriding parts of the App class + */ + +// biome-ignore lint/suspicious/noExplicitAny: module overrides can be anything +export interface Override extends Record> {} + +export function mergeOverrides(...overrides: Override[]): Override { + let currentOverrides: Override = {}; + for (const override of overrides) { + currentOverrides = mergeObjProperties(currentOverrides, override); + } + return currentOverrides; +} + +function mergeObjProperties(first: Override, second: Override): Override { + const merged: Override = {}; + const props = Object.keys(first).concat(Object.keys(second)); + for (const prop of props) { + if (second[prop] === undefined && first[prop] !== undefined) { + merged[prop] = first[prop]; + } else if (first[prop] === undefined && second[prop] !== undefined) { + merged[prop] = second[prop]; + } else { + // second always overwrites the first + merged[prop] = { ...first[prop], ...second[prop] }; + } + } + return merged; +} + +/** + * Helps with importing the App class and overriding certain aspects of it, like its setting of request metadata and ensuring the API client within doesnt issue network requests. + */ +export async function importApp( + overrides: Override = mergeOverrides(withNoopAppMetadata(), withNoopWebClient()), +): Promise { + return (await rewiremock.module(() => import('../../../src/App'), overrides)).default; +} + +// Composable overrides +export function withNoopWebClient(authTestResponse?: AuthTestResponse): Override { + return { + '@slack/web-api': { + WebClient: authTestResponse + ? class { + public token?: string; + + public constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } + + public auth = { + test: sinon.fake.resolves(authTestResponse), + }; + } + : class {}, + }, + }; +} + +export function withNoopAppMetadata(): Override { + return { + '@slack/web-api': { + addAppMetadata: sinon.fake(), + }, + }; +} + +export function withMemoryStore(spy: SinonSpy): Override { + return { + './conversation-store': { + MemoryStore: spy, + }, + }; +} + +export function withConversationContext(spy: SinonSpy): Override { + return { + './conversation-store': { + conversationContext: spy, + }, + }; +} + +export function withPostMessage(spy: SinonSpy): Override { + return { + '@slack/web-api': { + WebClient: class { + public chat = { + postMessage: spy, + }; + }, + }, + }; +} + +export function withAxiosPost(spy: SinonSpy): Override { + return { + axios: { + create: () => ({ + post: spy, + }), + }, + }; +} + +export function withSuccessfulBotUserFetchingWebClient(botId: string, botUserId: string): Override { + return { + '@slack/web-api': { + WebClient: class { + public token?: string; + + public constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } + + public auth = { + test: sinon.fake.resolves({ user_id: botUserId }), + }; + + public users = { + info: sinon.fake.resolves({ + user: { + profile: { + bot_id: botId, + }, + }, + }), + }; + }, + }, + }; +} diff --git a/test/unit/helpers/events.ts b/test/unit/helpers/events.ts new file mode 100644 index 000000000..9d612024d --- /dev/null +++ b/test/unit/helpers/events.ts @@ -0,0 +1,525 @@ +import type { + AppHomeOpenedEvent, + AppMentionEvent, + AssistantThreadContextChangedEvent, + AssistantThreadStartedEvent, + Block, + GenericMessageEvent, + KnownBlock, + MessageEvent, + ReactionAddedEvent, +} from '@slack/types'; +import { WebClient } from '@slack/web-api'; +import sinon, { type SinonSpy } from 'sinon'; +import { createFakeLogger } from '.'; +import type { + AssistantThreadContextChangedMiddlewareArgs, + AssistantThreadStartedMiddlewareArgs, + AssistantUserMessageMiddlewareArgs, +} from '../../../src/Assistant'; +import type { + AckFn, + AllMiddlewareArgs, + AnyMiddlewareArgs, + BaseSlackEvent, + BlockAction, + BlockElementAction, + BlockSuggestion, + Context, + EnvelopedEvent, + GlobalShortcut, + MessageShortcut, + ReceiverEvent, + RespondFn, + SayFn, + SlackActionMiddlewareArgs, + SlackCommandMiddlewareArgs, + SlackEventMiddlewareArgs, + SlackOptionsMiddlewareArgs, + SlackShortcutMiddlewareArgs, + SlackViewMiddlewareArgs, + SlashCommand, + ViewClosedAction, + ViewOutput, + ViewSubmitAction, +} from '../../../src/types'; + +const ts = '1234.56'; +const user = 'U1234'; +const team = 'T1234'; +const channel = 'C1234'; +const token = 'xoxb-1234'; +const app_id = 'A1234'; +const say: SayFn = (_msg) => Promise.resolve({ ok: true }); +const respond: RespondFn = (_msg) => Promise.resolve(); +const ack: AckFn = (_r?) => Promise.resolve(); + +export function wrapMiddleware( + args: Args, + ctx?: Context, +): Args & AllMiddlewareArgs & { next: SinonSpy } { + const wrapped = { + ...args, + context: ctx || { isEnterpriseInstall: false }, + logger: createFakeLogger(), + client: new WebClient(), + next: sinon.fake(), + }; + return wrapped; +} + +interface DummyAppHomeOpenedOverrides { + channel?: string; + user?: string; +} +export function createDummyAppHomeOpenedEventMiddlewareArgs( + eventOverrides?: DummyAppHomeOpenedOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'app_home_opened'> { + const event: AppHomeOpenedEvent = { + type: 'app_home_opened', + channel: eventOverrides?.channel || channel, + user: eventOverrides?.user || user, + tab: 'home', + event_ts: ts, + }; + return { + payload: event, + event, + body: envelopeEvent(event, bodyOverrides), + say, + }; +} + +interface DummyMemberChannelOverrides { + type: T; + channel?: string; + user?: string; + team?: string; +} +type MemberChannelEventTypes = 'member_joined_channel' | 'member_left_channel'; +export function createDummyMemberChannelEventMiddlewareArgs( + eventOverrides: DummyMemberChannelOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs { + const event = { + type: eventOverrides.type, + user: eventOverrides?.user || user, + channel: eventOverrides?.channel || channel, + channel_type: 'channel', + team: eventOverrides?.team || team, + event_ts: ts, + }; + return { + payload: event, + event, + body: envelopeEvent(event, bodyOverrides), + say, + }; +} + +interface DummyReactionAddedOverrides { + channel?: string; + user?: string; + reaction?: string; +} +export function createDummyReactionAddedEventMiddlewareArgs( + eventOverrides?: DummyReactionAddedOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'reaction_added'> { + const event: ReactionAddedEvent = { + type: 'reaction_added', + user: eventOverrides?.user || user, + reaction: eventOverrides?.reaction || 'lol', + item_user: 'wut', + item: { + type: 'message', + channel: eventOverrides?.channel || channel, + ts, + }, + event_ts: ts, + }; + return { + payload: event, + event, + body: envelopeEvent(event, bodyOverrides), + say, + }; +} + +interface DummyMessageOverrides { + message?: MessageEvent; + text?: string; + user?: string; + blocks?: (KnownBlock | Block)[]; + channel_type?: GenericMessageEvent['channel_type']; + thread_ts?: string; + subtype?: GenericMessageEvent['subtype']; +} +export function createDummyMessageEventMiddlewareArgs( + msgOverrides?: DummyMessageOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'message'> { + const payload: MessageEvent = msgOverrides?.message || { + type: 'message', + subtype: msgOverrides?.subtype || undefined, + event_ts: ts, + channel, + channel_type: msgOverrides?.channel_type || 'channel', + user: msgOverrides?.user || user, + ts, + text: msgOverrides?.text || 'hi', + blocks: msgOverrides?.blocks || [], + ...(msgOverrides?.thread_ts ? { thread_ts: msgOverrides.thread_ts } : {}), + }; + return { + payload, + event: payload, + message: payload, + body: envelopeEvent(payload, bodyOverrides), + say, + }; +} + +interface DummyAppMentionOverrides { + event?: AppMentionEvent; + text?: string; +} +export function createDummyAppMentionEventMiddlewareArgs( + eventOverrides?: DummyAppMentionOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackEventMiddlewareArgs<'app_mention'> { + const payload: AppMentionEvent = eventOverrides?.event || { + type: 'app_mention', + text: eventOverrides?.text || 'hi', + channel, + ts, + event_ts: ts, + }; + return { + payload, + event: payload, + body: envelopeEvent(payload, bodyOverrides), + say, + }; +} +function enrichDummyAssistantMiddlewareArgs() { + return { + getThreadContext: sinon.spy(), + saveThreadContext: sinon.spy(), + setStatus: sinon.spy(), + setSuggestedPrompts: sinon.spy(), + setTitle: sinon.spy(), + }; +} +export function createDummyAssistantThreadContextChangedEventMiddlewareArgs( + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): AssistantThreadContextChangedMiddlewareArgs { + const payload: AssistantThreadContextChangedEvent = { + type: 'assistant_thread_context_changed', + assistant_thread: { + channel_id: channel, + context: { channel_id: channel, team_id: team }, + user_id: user, + thread_ts: ts, + }, + event_ts: ts, + }; + return { + payload, + event: payload, + body: envelopeEvent(payload, bodyOverrides), + say, + ...enrichDummyAssistantMiddlewareArgs(), + }; +} +export function createDummyAssistantThreadStartedEventMiddlewareArgs( + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): AssistantThreadStartedMiddlewareArgs { + const payload: AssistantThreadStartedEvent = { + type: 'assistant_thread_started', + assistant_thread: { + channel_id: channel, + context: { channel_id: channel, team_id: team }, + user_id: user, + thread_ts: ts, + }, + event_ts: ts, + }; + return { + payload, + event: payload, + body: envelopeEvent(payload, bodyOverrides), + say, + ...enrichDummyAssistantMiddlewareArgs(), + }; +} + +export function createDummyAssistantUserMessageEventMiddlewareArgs( + msgOverrides?: DummyMessageOverrides, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): AssistantUserMessageMiddlewareArgs { + return { + ...createDummyMessageEventMiddlewareArgs(msgOverrides || { thread_ts: ts, channel_type: 'im' }, bodyOverrides), + ...enrichDummyAssistantMiddlewareArgs(), + }; +} +interface DummyCommandOverride { + command?: string; + slashCommand?: SlashCommand; +} +export function createDummyCommandMiddlewareArgs(commandOverrides?: DummyCommandOverride): SlackCommandMiddlewareArgs { + const payload: SlashCommand = commandOverrides?.slashCommand || { + token, + command: commandOverrides?.command || '/slash', + text: 'yo', + response_url: 'https://slack.com', + trigger_id: ts, + user_id: user, + user_name: 'filmaj', + team_id: team, + team_domain: 'slack.com', + channel_id: channel, + channel_name: '#random', + api_app_id: app_id, + }; + return { + payload, + command: payload, + body: payload, + respond, + say, + ack: () => Promise.resolve(), + }; +} + +interface DummyBlockActionOverride { + action_id?: string; + block_id?: string; + action?: BlockElementAction; +} +export function createDummyBlockActionEventMiddlewareArgs( + actionOverrides?: DummyBlockActionOverride, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackActionMiddlewareArgs { + const act: BlockElementAction = actionOverrides?.action || { + type: 'button', + action_id: actionOverrides?.action_id || 'action_id', + block_id: actionOverrides?.block_id || 'block_id', + action_ts: ts, + text: { type: 'plain_text', text: 'hi' }, + }; + const payload: BlockAction = { + type: 'block_actions', + actions: [act], + team: { id: team, domain: 'slack.com' }, + user: { id: user, username: 'filmaj' }, + token, + response_url: 'https://slack.com', + trigger_id: ts, + api_app_id: app_id, + container: {}, + ...bodyOverrides, + }; + return { + payload: act, + action: act, + body: payload, + respond, + say, + ack, + }; +} + +interface DummyBlockSuggestionOverride { + action_id?: string; + block_id?: string; + options?: BlockSuggestion; +} +export function createDummyBlockSuggestionsMiddlewareArgs( + optionsOverrides?: DummyBlockSuggestionOverride, +): SlackOptionsMiddlewareArgs { + const options: BlockSuggestion = optionsOverrides?.options || { + type: 'block_suggestion', + action_id: optionsOverrides?.action_id || 'action_id', + block_id: optionsOverrides?.block_id || 'block_id', + value: 'value', + action_ts: ts, + api_app_id: app_id, + team: { id: team, domain: 'slack.com' }, + user: { id: user, name: 'filmaj' }, + token, + container: {}, + }; + return { + payload: options, + body: options, + options, + ack: () => Promise.resolve(), + }; +} + +function createDummyViewOutput(viewOverrides?: Partial): ViewOutput { + return { + type: 'view', + id: 'V1234', + callback_id: 'Cb1234', + team_id: team, + app_id, + bot_id: 'B1234', + title: { type: 'plain_text', text: 'hi' }, + blocks: [], + close: null, + submit: null, + hash: ts, + state: { values: {} }, + private_metadata: '', + root_view_id: null, + previous_view_id: null, + clear_on_close: false, + notify_on_close: false, + ...viewOverrides, + }; +} + +export function createDummyViewSubmissionMiddlewareArgs( + viewOverrides?: Partial, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackViewMiddlewareArgs { + const payload = createDummyViewOutput(viewOverrides); + const event: ViewSubmitAction = { + type: 'view_submission', + team: { id: team, domain: 'slack.com' }, + user: { id: user, name: 'filmaj' }, + view: payload, + api_app_id: app_id, + token, + trigger_id: ts, + ...bodyOverrides, + }; + return { + payload, + view: payload, + body: event, + respond, + ack: () => Promise.resolve(), + }; +} + +export function createDummyViewClosedMiddlewareArgs( + viewOverrides?: Partial, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + bodyOverrides?: Record, +): SlackViewMiddlewareArgs { + const payload = createDummyViewOutput(viewOverrides); + const event: ViewClosedAction = { + type: 'view_closed', + team: { id: team, domain: 'slack.com' }, + user: { id: user, name: 'filmaj' }, + view: payload, + api_app_id: app_id, + token, + is_cleared: false, + ...bodyOverrides, + }; + return { + payload, + view: payload, + body: event, + respond, + ack: () => Promise.resolve(), + }; +} + +export function createDummyMessageShortcutMiddlewareArgs( + callback_id = 'Cb1234', + shortcut?: MessageShortcut, +): SlackShortcutMiddlewareArgs { + const payload: MessageShortcut = shortcut || { + type: 'message_action', + callback_id, + trigger_id: ts, + message_ts: ts, + response_url: 'https://slack.com', + message: { + type: 'message', + ts, + }, + user: { id: user, name: 'filmaj' }, + channel: { id: channel, name: '#random' }, + team: { id: team, domain: 'slack.com' }, + token, + action_ts: ts, + }; + return { + payload, + shortcut: payload, + body: payload, + respond, + ack: () => Promise.resolve(), + say, + }; +} + +export function createDummyGlobalShortcutMiddlewareArgs( + callback_id = 'Cb1234', + shortcut?: GlobalShortcut, +): SlackShortcutMiddlewareArgs { + const payload: GlobalShortcut = shortcut || { + type: 'shortcut', + callback_id, + trigger_id: ts, + user: { id: user, username: 'filmaj', team_id: team }, + team: { id: team, domain: 'slack.com' }, + token, + action_ts: ts, + }; + return { + payload, + shortcut: payload, + body: payload, + respond, + ack: () => Promise.resolve(), + }; +} + +function envelopeEvent( + evt: SlackEvent, + // biome-ignore lint/suspicious/noExplicitAny: allow mocking tools to provide any override + overrides?: Record, +): EnvelopedEvent { + const obj: EnvelopedEvent = { + token: 'xoxb-1234', + team_id: 'T1234', + api_app_id: 'A1234', + event: evt, + type: 'event_callback', + event_id: '1234', + event_time: 1234, + ...overrides, + }; + return obj; +} +// Dummies (values that have no real behavior but pass through the system opaquely) +export function createDummyReceiverEvent(type = 'dummy_event_type'): ReceiverEvent { + // NOTE: this is a degenerate ReceiverEvent that would successfully pass through the App. it happens to look like a + // IncomingEventType.Event + return { + body: { + event: { + type, + }, + }, + ack: () => Promise.resolve(), + }; +} diff --git a/test/unit/helpers/index.ts b/test/unit/helpers/index.ts new file mode 100644 index 000000000..35bdcbed8 --- /dev/null +++ b/test/unit/helpers/index.ts @@ -0,0 +1,34 @@ +import { ConsoleLogger } from '@slack/logger'; +import sinon from 'sinon'; +import type { ConversationStore } from '../../../src/conversation-store'; +import type { NextFn } from '../../../src/types'; + +export * from './app'; +export * from './events'; +export * from './receivers'; + +export function createFakeLogger() { + return sinon.createStubInstance(ConsoleLogger); +} + +export function delay(ms = 0): Promise { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +// biome-ignore lint/suspicious/noExplicitAny: mock function can accept anything +export const noop = (_args: any) => Promise.resolve({}); +// biome-ignore lint/suspicious/noExplicitAny: mock function can accept anything +export const noopVoid = (..._args: any[]) => Promise.resolve(); +export const noopMiddleware = async ({ next }: { next: NextFn }) => { + await next(); +}; + +export function createFakeConversationStore(): ConversationStore { + return { + get: (_id: string) => Promise.resolve({}), + // biome-ignore lint/suspicious/noExplicitAny: mocks can be anything + set: (_id: string, _val: any) => Promise.resolve({}), + }; +} diff --git a/test/unit/helpers/receivers.ts b/test/unit/helpers/receivers.ts new file mode 100644 index 000000000..3d5bd0f92 --- /dev/null +++ b/test/unit/helpers/receivers.ts @@ -0,0 +1,102 @@ +import crypto from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import type { AwsEvent } from '../../../src/receivers/AwsLambdaReceiver'; +import type { Receiver, ReceiverEvent } from '../../../src/types'; +import type { Override } from './app'; + +export class FakeReceiver implements Receiver { + private bolt: App | undefined; + + public init = (bolt: App) => { + this.bolt = bolt; + }; + + public start = sinon.fake( + (...params: Parameters): Promise => Promise.resolve([...params]), + ); + + public stop = sinon.fake( + (...params: Parameters): Promise => Promise.resolve([...params]), + ); + + public async sendEvent(event: ReceiverEvent): Promise { + return this.bolt?.processEvent(event); + } +} + +export class FakeServer extends EventEmitter { + public on = sinon.fake(); + + public listen = sinon.fake((_opts: Record, cb: () => void) => { + if (this.listeningFailure !== undefined) { + this.emit('error', this.listeningFailure); + } + if (cb) cb(); + }); + + // biome-ignore lint/suspicious/noExplicitAny: event handlers could accept anything as parameters + public close = sinon.fake((...args: any[]) => { + setImmediate(() => { + this.emit('close'); + setImmediate(() => { + args[0](this.closingFailure); + }); + }); + }); + + public constructor( + private listeningFailure?: Error, + private closingFailure?: Error, + ) { + super(); + } +} +export function withHttpCreateServer(spy: SinonSpy): Override { + return { + 'node:http': { + createServer: spy, + }, + }; +} + +export function withHttpsCreateServer(spy: SinonSpy): Override { + return { + 'node:https': { + createServer: spy, + }, + }; +} + +export function createDummyAWSPayload( + // biome-ignore lint/suspicious/noExplicitAny: HTTP request bodies can be anything + body: any, + timestamp: number = Math.floor(Date.now() / 1000), + headers?: Record, + isBase64Encoded = false, +): AwsEvent { + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const realBody = isBase64Encoded ? Buffer.from(body).toString('base64') : body; + return { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + headers: headers || { + Accept: 'application/json,*/*', + 'Content-Type': 'application/json', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }, + multiValueHeaders: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: realBody, + isBase64Encoded, + }; +} diff --git a/test/unit/middleware/builtin.spec.ts b/test/unit/middleware/builtin.spec.ts new file mode 100644 index 000000000..1567f9ffc --- /dev/null +++ b/test/unit/middleware/builtin.spec.ts @@ -0,0 +1,414 @@ +// import type { SlackEvent } from '@slack/types'; +import { assert } from 'chai'; +import rewiremock from 'rewiremock'; +import sinon from 'sinon'; +import { ErrorCode } from '../../../src/errors'; +// import { matchCommandName, matchEventType, onlyCommands, onlyEvents, subtype } from '../../../src/middleware/builtin'; +import type { Context, /* NextFn, */ SlackEventMiddlewareArgs } from '../../../src/types'; +import { + type Override, + createDummyAppHomeOpenedEventMiddlewareArgs, + createDummyAppMentionEventMiddlewareArgs, + createDummyCommandMiddlewareArgs, + createDummyMemberChannelEventMiddlewareArgs, + createDummyMessageEventMiddlewareArgs, + createDummyReactionAddedEventMiddlewareArgs, + wrapMiddleware, +} from '../helpers'; + +interface DummyContext extends Context { + matches?: RegExpExecArray; +} + +// Dummy values +const dummyContext: DummyContext = { isEnterpriseInstall: false }; +const ts = '1234.56'; +const channel = 'C1234'; + +async function importBuiltin(overrides: Override = {}): Promise { + return rewiremock.module(() => import('../../../src/middleware/builtin'), overrides); +} + +describe('Built-in global middleware', () => { + let builtins: Awaited>; + beforeEach(async () => { + builtins = await importBuiltin(); + }); + describe('matchMessage()', () => { + function matchesPatternTestCase( + pattern: string | RegExp, + event: SlackEventMiddlewareArgs<'message' | 'app_mention'>, + ): Mocha.AsyncFunc { + return async () => { + const { matchMessage } = builtins; + const middleware = matchMessage(pattern); + const ctx = { ...dummyContext }; + const args = wrapMiddleware(event, ctx); + await middleware(args); + + sinon.assert.called(args.next); + // The following assertion(s) check behavior that is only targeted at RegExp patterns + if (typeof pattern !== 'string') { + if (ctx.matches !== undefined) { + assert.lengthOf(ctx.matches, 1); + } else { + assert.fail(); + } + } + }; + } + + function notMatchesPatternTestCase( + pattern: string | RegExp, + event: SlackEventMiddlewareArgs<'message' | 'app_mention'>, + ): Mocha.AsyncFunc { + return async () => { + const { matchMessage } = builtins; + const middleware = matchMessage(pattern); + const ctx = { ...dummyContext }; + const args = wrapMiddleware(event, ctx); + await middleware(args); + + sinon.assert.notCalled(args.next); + assert.notProperty(ctx, 'matches'); + }; + } + + describe('using a string pattern', () => { + const pattern = 'foo'; + const matchingText = 'foobar'; + const nonMatchingText = 'bar'; + it( + 'should match message events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should match app_mention events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should filter out message events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out app_mention events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out message events which do not have text (block kit)', + notMatchesPatternTestCase( + pattern, + createDummyMessageEventMiddlewareArgs({ + text: '', + blocks: [ + { + type: 'divider', + }, + ], + }), + ), + ); + }); + + describe('using a RegExp pattern', () => { + const pattern = /foo/; + const matchingText = 'foobar'; + const nonMatchingText = 'bar'; + it( + 'should match message events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should match app_mention events with a pattern that matches', + matchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: matchingText })), + ); + it( + 'should filter out message events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyMessageEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out app_mention events with a pattern that does not match', + notMatchesPatternTestCase(pattern, createDummyAppMentionEventMiddlewareArgs({ text: nonMatchingText })), + ); + it( + 'should filter out message events which do not have text (block kit)', + + notMatchesPatternTestCase( + pattern, + createDummyMessageEventMiddlewareArgs({ + text: '', + blocks: [ + { + type: 'divider', + }, + ], + }), + ), + ); + }); + + describe('directMention()', () => { + it('should bail when the context does not provide a bot user ID', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs(), ctx); + + let error: Error | undefined = undefined; + try { + await builtins.directMention(args); + } catch (err) { + error = err as Error; + } + + assert.instanceOf(error, Error); + assert.propertyVal(error, 'code', ErrorCode.ContextMissingPropertyError); + assert.propertyVal(error, 'missingProperty', 'botUserId'); + }); + + it('should match message events that mention the bot user ID at the beginning of message text', async () => { + const fakeBotUserId = 'B123456'; + const messageText = `<@${fakeBotUserId}> hi`; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.called(args.next); + }); + + it('should not match message events that do not mention the bot user ID', async () => { + const fakeBotUserId = 'B123456'; + const messageText = 'hi'; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + + it('should not match message events that mention the bot user ID NOT at the beginning of message text', async () => { + const fakeBotUserId = 'B123456'; + const messageText = `hi <@${fakeBotUserId}> `; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + + it('should not match message events which do not have text (block kit)', async () => { + const fakeBotUserId = 'B123456'; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ blocks: [{ type: 'divider' }] }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + + it('should not match message events that contain a link to a conversation at the beginning', async () => { + const fakeBotUserId = 'B123456'; + const messageText = '<#C1234> hi'; + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ text: messageText }), ctx); + + await builtins.directMention(args); + + sinon.assert.notCalled(args.next); + }); + }); + + describe('ignoreSelf()', () => { + const fakeBotUserId = 'BUSER1'; + it('should continue middleware processing for non-event payloads', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs(), ctx); + await builtins.ignoreSelf(args); + sinon.assert.called(args.next); + }); + + it('should ignore message events identified as a bot message from the same bot ID as this app', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + const args = wrapMiddleware( + createDummyMessageEventMiddlewareArgs({ + message: { + bot_id: fakeBotUserId, + channel, + channel_type: 'channel', + event_ts: ts, + text: 'hi', + type: 'message', + ts, + subtype: 'bot_message', + }, + }), + ctx, + ); + await builtins.ignoreSelf(args); + sinon.assert.notCalled(args.next); + }); + + it('should ignore events with only a botUserId', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId }; + const args = wrapMiddleware(createDummyReactionAddedEventMiddlewareArgs({ user: fakeBotUserId }), ctx); + await builtins.ignoreSelf(args); + sinon.assert.notCalled(args.next); + }); + + it('should ignore events that match own app', async () => { + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + const args = wrapMiddleware(createDummyReactionAddedEventMiddlewareArgs({ user: fakeBotUserId }), ctx); + await builtins.ignoreSelf(args); + sinon.assert.notCalled(args.next); + }); + + it('should not filter `member_joined_channel` and `member_left_channel` events originating from own app', async () => { + const eventsWhichShouldNotBeFilteredOut = ['member_joined_channel', 'member_left_channel'] as const; + const ctx = { ...dummyContext, botUserId: fakeBotUserId, botId: fakeBotUserId }; + + const listOfFakeArgs = eventsWhichShouldNotBeFilteredOut.map((type) => + wrapMiddleware(createDummyMemberChannelEventMiddlewareArgs({ type, user: fakeBotUserId }), ctx), + ); + + await Promise.all(listOfFakeArgs.map(builtins.ignoreSelf)); + for (const args of listOfFakeArgs) { + sinon.assert.called(args.next); + } + }); + }); + + describe('onlyCommands', () => { + it('should continue middleware processing for a command payload', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs(), ctx); + await builtins.onlyCommands(args); + sinon.assert.called(args.next); + }); + + it('should ignore non-command payloads', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyReactionAddedEventMiddlewareArgs(), ctx); + await builtins.onlyCommands(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('matchCommandName', () => { + it('should continue middleware processing for requests that match exactly', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.matchCommandName('/hi')(args); + sinon.assert.called(args.next); + }); + + it('should continue middleware processing for requests that match a pattern', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.matchCommandName(/h/)(args); + sinon.assert.called(args.next); + }); + + it('should skip other requests', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.matchCommandName('/will-not-match')(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('onlyEvents', () => { + it('should continue middleware processing for valid requests', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.onlyEvents(args); + sinon.assert.called(args.next); + }); + + it('should skip other requests', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyCommandMiddlewareArgs({ command: '/hi' }), ctx); + await builtins.onlyEvents(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('matchEventType', () => { + it('should continue middleware processing for when event type matches', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.matchEventType('app_mention')(args); + sinon.assert.called(args.next); + }); + + it('should continue middleware processing for if RegExp match occurs on event type', async () => { + const ctx = { ...dummyContext }; + const appMentionArgs = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + const appHomeArgs = wrapMiddleware(createDummyAppHomeOpenedEventMiddlewareArgs(), ctx); + const middleware = builtins.matchEventType(/app_mention|app_home_opened/); + await middleware(appMentionArgs); + sinon.assert.called(appMentionArgs.next); + await middleware(appHomeArgs); + sinon.assert.called(appHomeArgs.next); + }); + + it('should skip non-matching event types', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.matchEventType('app_home_opened')(args); + sinon.assert.notCalled(args.next); + }); + + it('should skip non-matching event types via RegExp', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx); + await builtins.matchEventType(/foo/)(args); + sinon.assert.notCalled(args.next); + }); + }); + + describe('subtype', () => { + it('should continue middleware processing for match message subtypes', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware( + createDummyMessageEventMiddlewareArgs({ + message: { + bot_id: 'B1234', + channel, + channel_type: 'channel', + event_ts: ts, + text: 'hi', + type: 'message', + ts, + subtype: 'bot_message', + }, + }), + ctx, + ); + await builtins.subtype('bot_message')(args); + sinon.assert.called(args.next); + }); + + it('should skip non-matching message subtypes', async () => { + const ctx = { ...dummyContext }; + const args = wrapMiddleware( + createDummyMessageEventMiddlewareArgs({ + message: { + bot_id: 'B1234', + channel, + channel_type: 'channel', + event_ts: ts, + text: 'hi', + type: 'message', + ts, + subtype: 'bot_message', + }, + }), + ctx, + ); + await builtins.subtype('me_message')(args); + sinon.assert.notCalled(args.next); + }); + }); + }); +}); diff --git a/test/unit/receivers/AwsLambdaReceiver.spec.ts b/test/unit/receivers/AwsLambdaReceiver.spec.ts new file mode 100644 index 000000000..63ebf3ea8 --- /dev/null +++ b/test/unit/receivers/AwsLambdaReceiver.spec.ts @@ -0,0 +1,306 @@ +import crypto from 'node:crypto'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import AwsLambdaReceiver from '../../../src/receivers/AwsLambdaReceiver'; +import { + createDummyAWSPayload, + createDummyAppMentionEventMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopVoid, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +const fakeAuthTestResponse = { + ok: true, + enterprise_id: 'E111', + team_id: 'T111', + bot_id: 'B111', + user_id: 'W111', +}; +const appOverrides = mergeOverrides(withNoopAppMetadata(), withNoopWebClient(fakeAuthTestResponse)); + +describe('AwsLambdaReceiver', () => { + const noopLogger = createFakeLogger(); + + it('should instantiate with default logger', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + assert.isNotNull(awsReceiver); + }); + + it('should have start method', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const startedHandler = await awsReceiver.start(); + assert.isNotNull(startedHandler); + }); + + it('should have stop method', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + await awsReceiver.start(); + await awsReceiver.stop(); + }); + + it('should return a 404 if app has no registered handlers for an incoming event, and return a 200 if app does have registered handlers', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const awsEvent = createDummyAWSPayload(body, timestamp); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.event('app_mention', noopVoid); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept proxy events with lowercase header properties', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + accept: 'application/json,*/*', + 'content-type': 'application/json', + host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'user-agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'x-slack-request-timestamp': `${timestamp}`, + 'x-slack-signature': `v0=${signature}`, + }); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.event('app_mention', noopVoid); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept interactivity requests as form-encoded payload', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = + 'payload=%7B%22type%22%3A%22shortcut%22%2C%22token%22%3A%22fixed-value%22%2C%22action_ts%22%3A%221612879511.716075%22%2C%22team%22%3A%7B%22id%22%3A%22T111%22%2C%22domain%22%3A%22domain-value%22%2C%22enterprise_id%22%3A%22E111%22%2C%22enterprise_name%22%3A%22Sandbox+Org%22%7D%2C%22user%22%3A%7B%22id%22%3A%22W111%22%2C%22username%22%3A%22primary-owner%22%2C%22team_id%22%3A%22T111%22%7D%2C%22is_enterprise_install%22%3Afalse%2C%22enterprise%22%3A%7B%22id%22%3A%22E111%22%2C%22name%22%3A%22Kaz+SDK+Sandbox+Org%22%7D%2C%22callback_id%22%3A%22bolt-js-aws-lambda-shortcut%22%2C%22trigger_id%22%3A%22111.222.xxx%22%7D'; + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.shortcut('bolt-js-aws-lambda-shortcut', async ({ ack }) => { + await ack(); + }); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept slash commands with form-encoded body', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const body = + 'token=fixed-value&team_id=T111&team_domain=domain-value&channel_id=C111&channel_name=random&user_id=W111&user_name=primary-owner&command=%2Fhello-bolt-js&text=&api_app_id=A111&is_enterprise_install=false&enterprise_id=E111&enterprise_name=Sandbox+Org&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT111%2F111%2Fxxx&trigger_id=111.222.xxx'; + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + const App = await importApp(appOverrides); + const app = new App({ + token: 'xoxb-', + receiver: awsReceiver, + }); + app.command('/hello-bolt-js', async ({ ack }) => { + await ack(); + }); + const response2 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response2.statusCode, 200); + }); + + it('should accept an event containing a base64 encoded body', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const awsEvent = createDummyAWSPayload(body, timestamp, undefined, true); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + }); + + it('should accept ssl_check requests', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const body = 'ssl_check=1&token=legacy-fixed-token'; + const timestamp = Math.floor(Date.now() / 1000); + const signature = crypto.createHmac('sha256', 'my-secret').update(`v0:${timestamp}:${body}`).digest('hex'); + const awsEvent = createDummyAWSPayload(body, timestamp, { + Accept: 'application/json,*/*', + 'Content-Type': 'application/x-www-form-urlencoded', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}`, + }); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 200); + }); + + const urlVerificationBody = JSON.stringify({ + token: 'Jhj5dZrVaK7ZwHHjRyZWjbDl', + challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', + type: 'url_verification', + }); + + it('should accept url_verification requests', async () => { + const timestamp = Math.floor(Date.now() / 1000); + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const awsEvent = createDummyAWSPayload(urlVerificationBody, timestamp); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 200); + }); + + it('should detect invalid signature', async () => { + const spy = sinon.spy(); + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + invalidRequestSignatureHandler: spy, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const signature = crypto + .createHmac('sha256', 'my-secret') + .update(`v0:${timestamp}:${urlVerificationBody}`) + .digest('hex'); + const awsEvent = { + resource: '/slack/events', + path: '/slack/events', + httpMethod: 'POST', + headers: { + Accept: 'application/json,*/*', + 'Content-Type': 'application/json', + Host: 'xxx.execute-api.ap-northeast-1.amazonaws.com', + 'User-Agent': 'Slackbot 1.0 (+https://api.slack.com/robots)', + 'X-Slack-Request-Timestamp': `${timestamp}`, + 'X-Slack-Signature': `v0=${signature}XXXXXXXX`, // invalid signature + }, + multiValueHeaders: {}, + queryStringParameters: {}, + multiValueQueryStringParameters: {}, + pathParameters: null, + stageVariables: null, + requestContext: {}, + body: urlVerificationBody, + isBase64Encoded: false, + }; + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 401); + assert(spy.calledOnce); + }); + + it('should detect too old request timestamp', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: 'my-secret', + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes ago + const awsEvent = createDummyAWSPayload(urlVerificationBody, timestamp); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 401); + }); + + it('does not perform signature verification if signature verification flag is set to false', async () => { + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: '', + signatureVerification: false, + logger: noopLogger, + }); + const handler = awsReceiver.toHandler(); + const awsEvent = createDummyAWSPayload(urlVerificationBody); + const response = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response.statusCode, 200); + }); + + it('should not log an error regarding ack timeout if app has no handlers registered', async () => { + const delay = 10; + const awsReceiver = new AwsLambdaReceiver({ + signingSecret: '', + signatureVerification: false, + logger: noopLogger, + unhandledRequestTimeoutMillis: delay, + }); + const handler = awsReceiver.toHandler(); + const timestamp = Math.floor(Date.now() / 1000); + const args = createDummyAppMentionEventMiddlewareArgs(); + const body = JSON.stringify(args.body); + const awsEvent = createDummyAWSPayload(body, timestamp); + const response1 = await handler(awsEvent, {}, (_error, _result) => {}); + assert.equal(response1.statusCode, 404); + await new Promise((res) => { + setTimeout(res, delay + 2); + }); + sinon.assert.notCalled(awsReceiver.logger.error as sinon.SinonSpy); + }); +}); diff --git a/src/receivers/ExpressReceiver.spec.ts b/test/unit/receivers/ExpressReceiver.spec.ts similarity index 51% rename from src/receivers/ExpressReceiver.spec.ts rename to test/unit/receivers/ExpressReceiver.spec.ts index 8497be08f..fabae72ef 100644 --- a/src/receivers/ExpressReceiver.spec.ts +++ b/test/unit/receivers/ExpressReceiver.spec.ts @@ -1,96 +1,70 @@ -import 'mocha'; -import { Readable } from 'stream'; -import { EventEmitter } from 'events'; -import sinon, { SinonFakeTimers, SinonSpy } from 'sinon'; +import type { Server } from 'node:http'; +import type { Server as HTTPSServer } from 'node:https'; +import { Readable } from 'node:stream'; +import type { InstallProvider } from '@slack/oauth'; import { assert } from 'chai'; +import type { Application, IRouter, Request, Response } from 'express'; import rewiremock from 'rewiremock'; -import { Logger, LogLevel } from '@slack/logger'; -import { Application, IRouter, Request, Response } from 'express'; -import { Override, mergeOverrides, createFakeLogger } from '../test-helpers'; -import { ErrorCode, CodedError, ReceiverInconsistentStateError, AppInitializationError, AuthorizationError } from '../errors'; -import { HTTPModuleFunctions as httpFunc } from './HTTPModuleFunctions'; -import App from '../App'; - +import sinon, { type SinonFakeTimers } from 'sinon'; +import App from '../../../src/App'; +import { + AppInitializationError, + AuthorizationError, + ErrorCode, + ReceiverInconsistentStateError, +} from '../../../src/errors'; import ExpressReceiver, { respondToSslCheck, respondToUrlVerification, verifySignatureAndParseRawBody, buildBodyParserMiddleware, -} from './ExpressReceiver'; - -// Fakes -class FakeServer extends EventEmitter { - public on = sinon.fake(); - - public listen = sinon.fake((...args: any[]) => { - if (this.listeningFailure !== undefined) { - this.emit('error', this.listeningFailure); - return; - } - setImmediate(() => { - args[1](); - }); - }); +} from '../../../src/receivers/ExpressReceiver'; +import * as httpFunc from '../../../src/receivers/HTTPModuleFunctions'; +import type { ReceiverEvent } from '../../../src/types'; +import { + FakeServer, + type Override, + createFakeLogger, + mergeOverrides, + withHttpCreateServer, + withHttpsCreateServer, +} from '../helpers'; - public close = sinon.fake((...args: any[]) => { - setImmediate(() => { - this.emit('close'); - setImmediate(() => { - args[0](this.closingFailure); - }); - }); - }); +// Loading the system under test using overrides +async function importExpressReceiver( + overrides: Override = {}, +): Promise { + return (await rewiremock.module(() => import('../../../src/receivers/ExpressReceiver'), overrides)).default; +} - public constructor(private listeningFailure?: Error, private closingFailure?: Error) { - super(); - } +// biome-ignore lint/suspicious/noExplicitAny: accept any kind of mock response +function buildResponseToVerify(result: any): Response { + return { + status: (code: number) => { + result.code = code; + return { + send: () => { + result.sent = true; + }, + } as Response; + }, + } as Response; } -describe('ExpressReceiver', function () { - beforeEach(function () { - this.fakeServer = new FakeServer(); - this.fakeCreateServer = sinon.fake.returns(this.fakeServer); +describe('ExpressReceiver', () => { + const noopLogger = createFakeLogger(); + let fakeServer: FakeServer; + let fakeCreateServer: sinon.SinonSpy; + let overrides: Override; + beforeEach(() => { + fakeServer = new FakeServer(); + fakeCreateServer = sinon.fake.returns(fakeServer); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); }); - const noopLogger: Logger = { - debug(..._msg: any[]): void { - /* noop */ - }, - info(..._msg: any[]): void { - /* noop */ - }, - warn(..._msg: any[]): void { - /* noop */ - }, - error(..._msg: any[]): void { - /* noop */ - }, - setLevel(_level: LogLevel): void { - /* noop */ - }, - getLevel(): LogLevel { - return LogLevel.DEBUG; - }, - setName(_name: string): void { - /* noop */ - }, - }; - - function buildResponseToVerify(result: any): Response { - return { - status: (code: number) => { - // eslint-disable-next-line no-param-reassign - result.code = code; - return { - send: () => { - // eslint-disable-next-line no-param-reassign - result.sent = true; - }, - } as any as Response; - }, - } as any as Response; - } - describe('constructor', () => { // NOTE: it would be more informative to test known valid combinations of options, as well as invalid combinations it('should accept supported arguments', async () => { @@ -112,14 +86,14 @@ describe('ExpressReceiver', function () { assert.isNotNull(receiver); }); it('should accept custom Express app / router', async () => { - const app: Application = { + const app = { use: sinon.fake(), - } as unknown as Application; - const router: IRouter = { + }; + const router = { get: sinon.fake(), post: sinon.fake(), use: sinon.fake(), - } as unknown as IRouter; + }; const receiver = new ExpressReceiver({ signingSecret: 'my-secret', logger: noopLogger, @@ -133,15 +107,15 @@ describe('ExpressReceiver', function () { authVersion: 'v2', userScopes: ['chat:write'], }, - app, - router, + app: app as unknown as Application, + router: router as unknown as IRouter, }); assert.isNotNull(receiver); - assert((app.use as any).calledOnce); - assert((router.get as any).called); - assert((router.post as any).calledOnce); + sinon.assert.calledOnce(app.use); + sinon.assert.calledOnce(router.get); + sinon.assert.calledOnce(router.post); }); - it('should throw an error if redirect uri options supplied invalid or incomplete', async function () { + it('should throw an error if redirect uri options supplied invalid or incomplete', async () => { const clientId = 'my-clientId'; const clientSecret = 'my-clientSecret'; const signingSecret = 'secret'; @@ -163,81 +137,82 @@ describe('ExpressReceiver', function () { }); assert.isNotNull(receiver); // missing redirectUriPath - assert.throws(() => new ExpressReceiver({ - clientId, - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - }), AppInitializationError); + assert.throws( + () => + new ExpressReceiver({ + clientId, + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + }), + AppInitializationError, + ); // inconsistent redirectUriPath - assert.throws(() => new ExpressReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - installerOptions: { - redirectUriPath: '/hiya', - }, - }), AppInitializationError); + assert.throws( + () => + new ExpressReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + installerOptions: { + redirectUriPath: '/hiya', + }, + }), + AppInitializationError, + ); // inconsistent redirectUri - assert.throws(() => new ExpressReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri: 'http://example.com/hiya', - installerOptions, - }), AppInitializationError); + assert.throws( + () => + new ExpressReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri: 'http://example.com/hiya', + installerOptions, + }), + AppInitializationError, + ); }); }); - describe('#start()', function () { - it('should start listening for requests using the built-in HTTP server', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('#start()', () => { + it('should start listening for requests using the built-in HTTP server', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act const server = await receiver.start(port); - // Assert - assert(this.fakeCreateServer.calledOnce); - assert.strictEqual(server, this.fakeServer); - assert(this.fakeServer.listen.calledWith(port)); + sinon.assert.calledOnce(fakeCreateServer); + assert.strictEqual(server, fakeServer as unknown as Server); + sinon.assert.calledWith(fakeServer.listen, port); }); - it('should start listening for requests using the built-in HTTPS (TLS) server when given TLS server options', async function () { - // Arrange - const overrides = mergeOverrides( + it('should start listening for requests using the built-in HTTPS (TLS) server when given TLS server options', async () => { + overrides = mergeOverrides( withHttpCreateServer(sinon.fake.throws('Should not be used.')), - withHttpsCreateServer(this.fakeCreateServer), + withHttpsCreateServer(fakeCreateServer), ); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; const tlsOptions = { key: '', cert: '' }; - // Act const server = await receiver.start(port, tlsOptions); - // Assert - assert(this.fakeCreateServer.calledOnceWith(tlsOptions)); - assert.strictEqual(server, this.fakeServer); - assert(this.fakeServer.listen.calledWith(port)); + sinon.assert.calledWith(fakeCreateServer, tlsOptions); + assert.strictEqual(server, fakeServer as unknown as HTTPSServer); + sinon.assert.calledWith(fakeServer.listen, port); }); - it('should reject with an error when the built-in HTTP server fails to listen (such as EADDRINUSE)', async function () { - // Arrange + it('should reject with an error when the built-in HTTP server fails to listen (such as EADDRINUSE)', async () => { const fakeCreateFailingServer = sinon.fake.returns(new FakeServer(new Error('fake listening error'))); - const overrides = mergeOverrides( + overrides = mergeOverrides( withHttpCreateServer(fakeCreateFailingServer), withHttpsCreateServer(sinon.fake.throws('Should not be used.')), ); @@ -245,21 +220,18 @@ describe('ExpressReceiver', function () { const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act let caughtError: Error | undefined; try { await receiver.start(port); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert assert.instanceOf(caughtError, Error); }); - it('should reject with an error when the built-in HTTP server returns undefined', async function () { - // Arrange + it('should reject with an error when the built-in HTTP server returns undefined', async () => { const fakeCreateUndefinedServer = sinon.fake.returns(undefined); - const overrides = mergeOverrides( + overrides = mergeOverrides( withHttpCreateServer(fakeCreateUndefinedServer), withHttpsCreateServer(sinon.fake.throws('Should not be used.')), ); @@ -267,255 +239,190 @@ describe('ExpressReceiver', function () { const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act let caughtError: Error | undefined; try { await receiver.start(port); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert assert.instanceOf(caughtError, ReceiverInconsistentStateError); - assert.equal((caughtError as CodedError).code, ErrorCode.ReceiverInconsistentStateError); + assert.propertyVal(caughtError, 'code', ErrorCode.ReceiverInconsistentStateError); }); - it('should reject with an error when starting and the server was already previously started', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should reject with an error when starting and the server was already previously started', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; - // Act let caughtError: Error | undefined; await receiver.start(port); try { await receiver.start(port); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert assert.instanceOf(caughtError, ReceiverInconsistentStateError); - assert.equal((caughtError as CodedError).code, ErrorCode.ReceiverInconsistentStateError); + assert.propertyVal(caughtError, 'code', ErrorCode.ReceiverInconsistentStateError); }); }); - describe('#stop()', function () { - it('should stop listening for requests when a built-in HTTP server is already started', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('#stop()', () => { + it('should stop listening for requests when a built-in HTTP server is already started', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; await receiver.start(port); - // Act await receiver.stop(); - - // Assert - // As long as control reaches this point, the test passes - assert.isOk(true); }); - it('should reject when a built-in HTTP server is not started', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should reject when a built-in HTTP server is not started', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - // Act let caughtError: Error | undefined; try { await receiver.stop(); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert - // As long as control reaches this point, the test passes assert.instanceOf(caughtError, ReceiverInconsistentStateError); - assert.equal((caughtError as CodedError).code, ErrorCode.ReceiverInconsistentStateError); + assert.propertyVal(caughtError, 'code', ErrorCode.ReceiverInconsistentStateError); }); - it('should reject when a built-in HTTP server raises an error when closing', async function () { - // Arrange - this.fakeServer = new FakeServer(undefined, new Error('this error will be raised by the underlying HTTP server during close()')); - this.fakeCreateServer = sinon.fake.returns(this.fakeServer); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), + it('should reject when a built-in HTTP server raises an error when closing', async () => { + fakeServer = new FakeServer( + undefined, + new Error('this error will be raised by the underlying HTTP server during close()'), + ); + fakeCreateServer = sinon.fake.returns(fakeServer); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), withHttpsCreateServer(sinon.fake.throws('Should not be used.')), ); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); await receiver.start(12345); - // Act let caughtError: Error | undefined; try { await receiver.stop(); - } catch (error: any) { - caughtError = error; + } catch (error) { + caughtError = error as Error; } - // Assert - // As long as control reaches this point, the test passes assert.instanceOf(caughtError, Error); assert.equal(caughtError?.message, 'this error will be raised by the underlying HTTP server during close()'); }); }); - describe('#requestHandler()', function () { - before(function () { - this.extractRetryNumStub = sinon.stub(httpFunc, 'extractRetryNumFromHTTPRequest'); - this.extractRetryReasonStub = sinon.stub(httpFunc, 'extractRetryReasonFromHTTPRequest'); - this.buildNoBodyResponseStub = sinon.stub(httpFunc, 'buildNoBodyResponse'); - this.buildContentResponseStub = sinon.stub(httpFunc, 'buildContentResponse'); - this.processStub = sinon.stub().resolves({}); - this.ackStub = function ackStub() {}; - this.ackStub.prototype.bind = function () { return this; }; - this.ackStub.prototype.ack = sinon.spy(); + describe('#requestHandler()', () => { + const extractRetryNumStub = sinon.stub(httpFunc, 'extractRetryNumFromHTTPRequest'); + const extractRetryReasonStub = sinon.stub(httpFunc, 'extractRetryReasonFromHTTPRequest'); + const buildNoBodyResponseStub = sinon.stub(httpFunc, 'buildNoBodyResponse'); + const buildContentResponseStub = sinon.stub(httpFunc, 'buildContentResponse'); + const processStub = sinon.stub<[ReceiverEvent]>().resolves({}); + const ackStub = function ackStub() {}; + ackStub.prototype.bind = function () { + return this; + }; + ackStub.prototype.ack = sinon.spy(); + beforeEach(() => { + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + { './HTTPResponseAck': { HTTPResponseAck: ackStub } }, + ); }); afterEach(() => { sinon.reset(); }); - after(function () { - this.extractRetryNumStub.restore(); - this.extractRetryReasonStub.restore(); - this.buildNoBodyResponseStub.restore(); - this.buildContentResponseStub.restore(); + after(() => { + extractRetryNumStub.restore(); + extractRetryReasonStub.restore(); + buildNoBodyResponseStub.restore(); + buildContentResponseStub.restore(); }); - it('should not build an HTTP response if processBeforeResponse=false', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); + it('should not build an HTTP response if processBeforeResponse=false', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - const resp = { send: () => { } } as Response; + const req = { body: {} } as Request; + const resp = { send: () => {} } as Response; await receiver.requestHandler(req, resp); - // Assert - assert(this.buildContentResponseStub.notCalled, 'HTTPFunction buildContentResponse called incorrectly'); + sinon.assert.notCalled(buildContentResponseStub); }); - it('should build an HTTP response if processBeforeResponse=true', async function () { - // Arrange - this.processStub.callsFake((event: any) => { - // eslint-disable-next-line no-param-reassign + it('should build an HTTP response if processBeforeResponse=true', async () => { + // biome-ignore lint/suspicious/noExplicitAny: TODO: dig in to see what this type actually is supposed to be + processStub.callsFake((event: any) => { event.ack.storedResponse = 'something'; return Promise.resolve({}); }); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - const resp = { send: () => { } } as Response; + const req = { body: {} } as Request; + const resp = { send: () => {} } as Response; await receiver.requestHandler(req, resp); - // Assert - assert(this.buildContentResponseStub.called, 'HTTPFunction buildContentResponse not called incorrectly'); + sinon.assert.called(buildContentResponseStub); }); - it('should throw and build an HTTP 500 response with no body if processEvent raises an uncoded Error or a coded, non-Authorization Error', async function () { - // Arrange - this.processStub.callsFake(() => Promise.reject(new Error('uh oh'))); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); + it('should throw and build an HTTP 500 response with no body if processEvent raises an uncoded Error or a coded, non-Authorization Error', async () => { + processStub.callsFake(() => Promise.reject(new Error('uh oh'))); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - let writeHeadStatus = 0; + const req = { body: {} } as Request; const resp = { - send: () => { }, - writeHead: (status: number) => { writeHeadStatus = status; }, - end: () => { }, - } as unknown as Response; - await receiver.requestHandler(req, resp); - - // Assert - assert.equal(writeHeadStatus, 500); + send: sinon.fake(), + writeHead: sinon.fake(), + end: sinon.fake(), + }; + await receiver.requestHandler(req, resp as unknown as Response); + sinon.assert.calledWith(resp.writeHead, 500); }); - it('should build an HTTP 401 response with no body and call ack() if processEvent raises a coded AuthorizationError', async function () { - // Arrange - this.processStub.callsFake(() => Promise.reject(new AuthorizationError('uh oh', new Error()))); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - { './HTTPResponseAck': { HTTPResponseAck: this.ackStub } }, - ); + it('should build an HTTP 401 response with no body and call ack() if processEvent raises a coded AuthorizationError', async () => { + processStub.callsFake(() => Promise.reject(new AuthorizationError('uh oh', new Error()))); const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); - const app = sinon.createStubInstance(App, { processEvent: this.processStub }) as unknown as App; + const app = sinon.createStubInstance(App, { processEvent: processStub }) as unknown as App; receiver.init(app); - // Act - const req = { body: { } } as Request; - let writeHeadStatus = 0; + const req = { body: {} } as Request; const resp = { - send: () => { }, - writeHead: (status: number) => { writeHeadStatus = status; }, - end: () => { }, - } as unknown as Response; - await receiver.requestHandler(req, resp); - // Assert - assert.equal(writeHeadStatus, 401); + send: sinon.fake(), + writeHead: sinon.fake(), + end: sinon.fake(), + }; + await receiver.requestHandler(req, resp as unknown as Response); + sinon.assert.calledWith(resp.writeHead, 401); }); }); - describe('oauth support', function () { - describe('install path route', function () { - it('should call into installer.handleInstallPath when HTTP GET request hits the install path', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('oauth support', () => { + describe('install path route', () => { + it('should call into installer.handleInstallPath when HTTP GET request hits the install path', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '', clientSecret: '', clientId: '', stateSecret: '' }); - const handleStub = sinon.stub(receiver.installer as any, 'handleInstallPath').resolves(); + const handleStub = sinon.stub(receiver.installer as InstallProvider, 'handleInstallPath').resolves(); - // Act - const req = { body: { }, url: 'http://localhost/slack/install', method: 'GET' } as Request; - const resp = { send: () => { } } as Response; + const req = { body: {}, url: 'http://localhost/slack/install', method: 'GET' } as Request; + const resp = { send: () => {} } as Response; const next = sinon.spy(); + // biome-ignore lint/suspicious/noExplicitAny: TODO: better way to get a reference to handle? dealing with express internals, unclear (receiver.router as any).handle(req, resp, next); - // Assert - assert(handleStub.calledWith(req, resp), 'installer.handleInstallPath not called'); + sinon.assert.calledWith(handleStub, req, resp); }); }); - describe('redirect path route', function () { - it('should call installer.handleCallback with callbackOptions when HTTP request hits the redirect URI path and stateVerification=true', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('redirect path route', () => { + it('should call installer.handleCallback with callbackOptions when HTTP request hits the redirect URI path and stateVerification=true', async () => { const ER = await importExpressReceiver(overrides); const callbackOptions = {}; const scopes = ['some']; @@ -523,22 +430,24 @@ describe('ExpressReceiver', function () { stateVerification: true, callbackOptions, }; - const receiver = new ER({ signingSecret: '', clientSecret: '', clientId: '', stateSecret: '', scopes, installerOptions }); - const handleStub = sinon.stub(receiver.installer as any, 'handleCallback').resolves('poop'); - - // Act - const req = { body: { }, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; - const resp = { send: () => { } } as Response; - (receiver.router as any).handle(req, resp); - - // Assert - assert(handleStub.calledWith(req, resp, callbackOptions), 'installer.handleCallback not called'); + const receiver = new ER({ + signingSecret: '', + clientSecret: '', + clientId: '', + stateSecret: '', + scopes, + installerOptions, + }); + const handleStub = sinon.stub(receiver.installer as InstallProvider, 'handleCallback').resolves(); + + const req = { body: {}, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; + const resp = { send: () => {} } as Response; + // biome-ignore lint/suspicious/noExplicitAny: TODO: better way to get a reference to handle? dealing with express internals, unclear + (receiver.router as any).handle(req, resp, () => {}); + + sinon.assert.calledWith(handleStub, req, resp, callbackOptions); }); - it('should call installer.handleCallback with callbackOptions and installUrlOptions when HTTP request hits the redirect URI path and stateVerification=false', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should call installer.handleCallback with callbackOptions and installUrlOptions when HTTP request hits the redirect URI path and stateVerification=false', async () => { const ER = await importExpressReceiver(overrides); const callbackOptions = {}; const scopes = ['some']; @@ -546,132 +455,83 @@ describe('ExpressReceiver', function () { stateVerification: false, callbackOptions, }; - const receiver = new ER({ signingSecret: '', clientSecret: '', clientId: '', stateSecret: '', scopes, installerOptions }); - const handleStub = sinon.stub(receiver.installer as any, 'handleCallback').resolves('poop'); - - // Act - const req = { body: { }, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; - const resp = { send: () => { } } as Response; - (receiver.router as any).handle(req, resp); - - // Assert - assert(handleStub.calledWith(req, resp, callbackOptions, sinon.match({ scopes })), 'installer.handleCallback not called'); + const receiver = new ER({ + signingSecret: '', + clientSecret: '', + clientId: '', + stateSecret: '', + scopes, + installerOptions, + }); + const handleStub = sinon.stub(receiver.installer as InstallProvider, 'handleCallback').resolves(); + + const req = { body: {}, url: 'http://localhost/slack/oauth_redirect', method: 'GET' } as Request; + const resp = { send: () => {} } as Response; + // biome-ignore lint/suspicious/noExplicitAny: TODO: better way to get a reference to handle? dealing with express internals, unclear + (receiver.router as any).handle(req, resp, () => {}); + + sinon.assert.calledWith(handleStub, req, resp, callbackOptions, sinon.match({ scopes })); }); }); }); - describe('state management for built-in server', function () { - it('should be able to start after it was stopped', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('state management for built-in server', () => { + it('should be able to start after it was stopped', async () => { const ER = await importExpressReceiver(overrides); const receiver = new ER({ signingSecret: '' }); const port = 12345; await receiver.start(port); await receiver.stop(); - - // Act await receiver.start(port); - - // Assert - // As long as control reaches this point, the test passes - assert.isOk(true); }); }); describe('built-in middleware', () => { describe('ssl_check request handler', () => { - it('should handle valid requests', async () => { - // Arrange + it('should handle valid ssl_check requests and not call next()', async () => { const req = { body: { ssl_check: 1 } } as Request; - let sent = false; const resp = { - send: () => { - sent = true; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + send: sinon.fake(), }; - - // Act - respondToSslCheck(req, resp, next); - - // Assert - assert.isTrue(sent); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToSslCheck(req, resp as unknown as Response, next); + sinon.assert.called(resp.send); + sinon.assert.notCalled(next); }); it('should work with other requests', async () => { - // Arrange const req = { body: { type: 'block_actions' } } as Request; - let sent = false; const resp = { - send: () => { - sent = true; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + send: sinon.fake(), }; - - // Act - respondToSslCheck(req, resp, next); - - // Assert - assert.isFalse(sent); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToSslCheck(req, resp as unknown as Response, next); + sinon.assert.notCalled(resp.send); + sinon.assert.called(next); }); }); describe('url_verification request handler', () => { it('should handle valid requests', async () => { - // Arrange const req = { body: { type: 'url_verification', challenge: 'this is it' } } as Request; - let sentBody; const resp = { - json: (body) => { - sentBody = body; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + json: sinon.fake(), }; - - // Act - respondToUrlVerification(req, resp, next); - - // Assert - assert.equal(JSON.stringify(sentBody), JSON.stringify({ challenge: 'this is it' })); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToUrlVerification(req, resp as unknown as Response, next); + sinon.assert.calledWith(resp.json, sinon.match({ challenge: 'this is it' })); + sinon.assert.notCalled(next); }); it('should work with other requests', async () => { - // Arrange const req = { body: { ssl_check: 1 } } as Request; - let sentBody; const resp = { - json: (body) => { - sentBody = body; - }, - } as Response; - let errorResult: any; - const next = (error: any) => { - errorResult = error; + json: sinon.fake(), }; - - // Act - respondToUrlVerification(req, resp, next); - - // Assert - assert.isUndefined(sentBody); - assert.isUndefined(errorResult); + const next = sinon.spy(); + respondToUrlVerification(req, resp as unknown as Response, next); + sinon.assert.notCalled(resp.json); + sinon.assert.called(next); }); }); }); @@ -693,13 +553,15 @@ describe('ExpressReceiver', function () { const signingSecret = '8f742231b10e8888abcd99yyyzzz85a5'; const signature = 'v0=a2114d57b48eac39b9ad189dd8316235a7b4a8d21a10bd27519666489c69b503'; const requestTimestamp = 1531420618; - const body = 'token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c'; + const body = + 'token=xyzz0WbapA4vBCDEFasx0q6G&team_id=T1DC2JH3J&team_domain=testteamnow&channel_id=G8PSS9T3V&channel_name=foobar&user_id=U2CERLKJA&user_name=roadrunner&command=%2Fwebhook-collect&text=&response_url=https%3A%2F%2Fhooks.slack.com%2Fcommands%2FT1DC2JH3J%2F397700885554%2F96rGlfmibIGlgcZRskXaIFfN&trigger_id=398738663015.47445629121.803a0bc887a14d10d2c447fce8b6703c'; function buildExpressRequest(): Request { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: mock requests can be anything + (reqAsStream as Record).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp, 'content-type': 'application/x-www-form-urlencoded', @@ -709,7 +571,8 @@ describe('ExpressReceiver', function () { } function buildGCPRequest(): Request { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: mock requests can be anything + const untypedReq: Record = { rawBody: body, headers: { 'x-slack-signature': signature, @@ -726,39 +589,37 @@ describe('ExpressReceiver', function () { async function runWithValidRequest( req: Request, + // biome-ignore lint/suspicious/noExplicitAny: mock requests can be anything state: any, signingSecretFn?: () => PromiseLike, ): Promise { - // Arrange const resp = buildResponseToVerify(state); + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const next = (error: any) => { - // eslint-disable-next-line no-param-reassign state.error = error; }; - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecretFn || signingSecret); await verifier(req, resp, next); } it('should verify requests', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; await runWithValidRequest(buildExpressRequest(), state); - // Assert assert.isUndefined(state.error); }); it('should verify requests on GCP', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; await runWithValidRequest(buildGCPRequest(), state); - // Assert assert.isUndefined(state.error); }); it('should verify requests on GCP using async signingSecret', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; await runWithValidRequest(buildGCPRequest(), state, () => Promise.resolve(signingSecret)); - // Assert assert.isUndefined(state.error); }); @@ -766,21 +627,21 @@ describe('ExpressReceiver', function () { // parse error it('should verify requests and then catch parse failures', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; const req = buildExpressRequest(); req.headers['content-type'] = undefined; await runWithValidRequest(req, state); - // Assert assert.equal(state.code, 400); assert.equal(state.sent, true); }); it('should verify requests on GCP and then catch parse failures', async () => { + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const state: any = {}; const req = buildGCPRequest(); req.headers['content-type'] = undefined; await runWithValidRequest(req, state); - // Assert assert.equal(state.code, 400); assert.equal(state.sent, true); }); @@ -789,17 +650,12 @@ describe('ExpressReceiver', function () { // verifyContentTypeAbsence async function verifyRequestsWithoutContentTypeHeader(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: errors can be anything const result: any = {}; const resp = buildResponseToVerify(result); - const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 400); assert.equal(result.sent, true); } @@ -808,7 +664,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp, // 'content-type': 'application/x-www-form-urlencoded', @@ -818,7 +675,8 @@ describe('ExpressReceiver', function () { }); it('should verify parse request body without content-type header on GCP', async () => { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + const untypedReq: any = { rawBody: body, headers: { 'x-slack-signature': signature, @@ -834,16 +692,12 @@ describe('ExpressReceiver', function () { // verifyMissingHeaderDetection async function verifyMissingHeaderDetection(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -852,7 +706,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { // 'x-slack-signature': signature , 'x-slack-request-timestamp': requestTimestamp, 'content-type': 'application/x-www-form-urlencoded', @@ -864,7 +719,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, /* 'x-slack-request-timestamp': requestTimestamp, */ 'content-type': 'application/x-www-form-urlencoded', @@ -873,11 +729,11 @@ describe('ExpressReceiver', function () { }); it('should detect headers missing on GCP', async () => { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + const untypedReq: any = { rawBody: body, headers: { 'x-slack-signature': signature, - /* 'x-slack-request-timestamp': requestTimestamp, */ 'content-type': 'application/x-www-form-urlencoded', }, }; @@ -888,17 +744,12 @@ describe('ExpressReceiver', function () { // verifyInvalidTimestampError async function verifyInvalidTimestampError(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act - const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -907,7 +758,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': 'Hello there!', 'content-type': 'application/x-www-form-urlencoded', @@ -919,19 +771,14 @@ describe('ExpressReceiver', function () { // verifyTooOldTimestampError async function verifyTooOldTimestampError(req: Request): Promise { - // Arrange // restore the valid clock clock.restore(); - + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -948,17 +795,13 @@ describe('ExpressReceiver', function () { // verifySignatureMismatch async function verifySignatureMismatch(req: Request): Promise { - // Arrange + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything const result: any = {}; const resp = buildResponseToVerify(result); const next = sinon.fake(); - - // Act const verifier = verifySignatureAndParseRawBody(noopLogger, signingSecret); verifier(req, resp, next); await verifier(req, resp, next); - - // Assert assert.equal(result.code, 401); assert.equal(result.sent, true); } @@ -967,7 +810,8 @@ describe('ExpressReceiver', function () { const reqAsStream = new Readable(); reqAsStream.push(body); reqAsStream.push(null); // indicate EOF - (reqAsStream as { [key: string]: any }).headers = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + (reqAsStream as any).headers = { 'x-slack-signature': signature, 'x-slack-request-timestamp': requestTimestamp + 10, 'content-type': 'application/x-www-form-urlencoded', @@ -977,7 +821,8 @@ describe('ExpressReceiver', function () { }); it('should detect signature mismatch on GCP', async () => { - const untypedReq: { [key: string]: any } = { + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything + const untypedReq: any = { rawBody: body, headers: { 'x-slack-signature': signature, @@ -991,87 +836,67 @@ describe('ExpressReceiver', function () { }); describe('buildBodyParserMiddleware', () => { - beforeEach(function () { - this.req = { body: { }, headers: { 'content-type': 'application/json' } } as Request; - this.res = { send: () => { } } as Response; - this.next = sinon.spy(); + // biome-ignore lint/suspicious/noExplicitAny: requests can be anything when testing + let req: any = { body: {}, headers: { 'content-type': 'application/json' } }; + const res = { send: sinon.spy() }; + const next = sinon.spy(); + beforeEach(() => { + req = { body: {}, headers: { 'content-type': 'application/json' } }; + res.send.resetHistory(); + next.resetHistory(); }); - it('should JSON.parse a stringified rawBody if exists on a application/json request', async function () { - this.req.rawBody = '{"awesome": true}'; + it('should JSON.parse a stringified rawBody if exists on a application/json request', async () => { + req.rawBody = '{"awesome": true}'; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body JSON was not parsed'); + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body JSON was not parsed'); }); - it('should querystring.parse a stringified rawBody if exists on a application/x-www-form-urlencoded request', async function () { - this.req.headers['content-type'] = 'application/x-www-form-urlencoded'; - this.req.rawBody = 'awesome=true'; + it('should querystring.parse a stringified rawBody if exists on a application/x-www-form-urlencoded request', async () => { + req.headers['content-type'] = 'application/x-www-form-urlencoded'; + req.rawBody = 'awesome=true'; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); }); - it('should JSON.parse a stringified rawBody payload if exists on a application/x-www-form-urlencoded request', async function () { - this.req.headers['content-type'] = 'application/x-www-form-urlencoded'; - this.req.rawBody = 'payload=%7B%22awesome%22:true%7D'; + it('should JSON.parse a stringified rawBody payload if exists on a application/x-www-form-urlencoded request', async () => { + req.headers['content-type'] = 'application/x-www-form-urlencoded'; + req.rawBody = 'payload=%7B%22awesome%22:true%7D'; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body form-urlencoded was not parsed'); - }); - it('should JSON.parse a body if exists on a application/json request', async function () { - this.req = new Readable(); - this.req.push('{"awesome": true}'); - this.req.push(null); - this.req.headers = { 'content-type': 'application/json' }; + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body form-urlencoded was not parsed'); + }); + it('should JSON.parse a body if exists on a application/json request', async () => { + req = new Readable(); + req.push('{"awesome": true}'); + req.push(null); + req.headers = { 'content-type': 'application/json' }; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body JSON was not parsed'); - }); - it('should querystring.parse a body if exists on a application/x-www-form-urlencoded request', async function () { - this.req = new Readable(); - this.req.push('awesome=true'); - this.req.push(null); - this.req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body JSON was not parsed'); + }); + it('should querystring.parse a body if exists on a application/x-www-form-urlencoded request', async () => { + req = new Readable(); + req.push('awesome=true'); + req.push(null); + req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); - }); - it('should JSON.parse a body payload if exists on a application/x-www-form-urlencoded request', async function () { - this.req = new Readable(); - this.req.push('payload=%7B%22awesome%22:true%7D'); - this.req.push(null); - this.req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; + await parser(req, res as unknown as Response, next); + assert(next.called, 'next() was not called'); + assert.equal(req.body.awesome, 'true', 'request body form-urlencoded was not parsed'); + }); + it('should JSON.parse a body payload if exists on a application/x-www-form-urlencoded request', async () => { + req = new Readable(); + req.push('payload=%7B%22awesome%22:true%7D'); + req.push(null); + req.headers = { 'content-type': 'application/x-www-form-urlencoded' }; const parser = buildBodyParserMiddleware(createFakeLogger()); - await parser(this.req, this.res, this.next); - assert(this.next.called, 'next() was not called'); - assert.equal(this.req.body.awesome, true, 'request body form-urlencoded was not parsed'); + await parser(req, res as unknown as Response, next); + sinon.assert.called(next); + assert.equal(req.body.awesome, true, 'request body form-urlencoded was not parsed'); }); }); }); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importExpressReceiver(overrides: Override = {}): Promise { - return (await rewiremock.module(() => import('./ExpressReceiver'), overrides)).default; -} - -// Composable overrides -function withHttpCreateServer(spy: SinonSpy): Override { - return { - http: { - createServer: spy, - }, - }; -} - -function withHttpsCreateServer(spy: SinonSpy): Override { - return { - https: { - createServer: spy, - }, - }; -} diff --git a/src/receivers/HTTPModuleFunctions.spec.ts b/test/unit/receivers/HTTPModuleFunctions.spec.ts similarity index 68% rename from src/receivers/HTTPModuleFunctions.spec.ts rename to test/unit/receivers/HTTPModuleFunctions.spec.ts index 0ab3da33d..7db177f25 100644 --- a/src/receivers/HTTPModuleFunctions.spec.ts +++ b/test/unit/receivers/HTTPModuleFunctions.spec.ts @@ -1,43 +1,29 @@ -import 'mocha'; -import { IncomingMessage, ServerResponse } from 'http'; -import { createHmac } from 'crypto'; -import sinon from 'sinon'; +import { createHmac } from 'node:crypto'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { assert } from 'chai'; +import sinon from 'sinon'; -import { - ReceiverMultipleAckError, - HTTPReceiverDeferredRequestError, - AuthorizationError, -} from '../errors'; -import { HTTPModuleFunctions as func } from './HTTPModuleFunctions'; -import { createFakeLogger } from '../test-helpers'; -import { BufferedIncomingMessage } from './BufferedIncomingMessage'; +import { AuthorizationError, HTTPReceiverDeferredRequestError, ReceiverMultipleAckError } from '../../../src/errors'; +import type { BufferedIncomingMessage } from '../../../src/receivers/BufferedIncomingMessage'; +import * as func from '../../../src/receivers/HTTPModuleFunctions'; +import { createFakeLogger } from '../helpers'; describe('HTTPModuleFunctions', async () => { describe('Request header extraction', async () => { describe('extractRetryNumFromHTTPRequest', async () => { it('should work when the header does not exist', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } const result = func.extractRetryNumFromHTTPRequest(req); assert.isUndefined(result); }); it('should parse a single value header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-num'] = '2'; const result = func.extractRetryNumFromHTTPRequest(req); assert.equal(result, 2); }); it('should parse an array of value headers', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-num'] = ['2']; const result = func.extractRetryNumFromHTTPRequest(req); assert.equal(result, 2); @@ -46,26 +32,17 @@ describe('HTTPModuleFunctions', async () => { describe('extractRetryReasonFromHTTPRequest', async () => { it('should work when the header does not exist', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } const result = func.extractRetryReasonFromHTTPRequest(req); assert.isUndefined(result); }); it('should parse a valid header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-reason'] = 'timeout'; const result = func.extractRetryReasonFromHTTPRequest(req); assert.equal(result, 'timeout'); }); it('should parse an array of value headers', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers['x-slack-retry-reason'] = ['timeout']; const result = func.extractRetryReasonFromHTTPRequest(req); assert.equal(result, 'timeout'); @@ -96,21 +73,15 @@ describe('HTTPModuleFunctions', async () => { describe('getHeader', async () => { it('should throw an exception when parsing a missing header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } try { func.getHeader(req, 'Cookie'); assert.fail('Error should be thrown here'); } catch (e) { - assert.isTrue((e as any).message.length > 0); + assert.isTrue((e as Error).message.length > 0); } }); it('should parse a valid header', async () => { const req = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - if (req.headers === undefined) { // sinon on older Node.js may not return an object here - req.headers = {}; - } req.headers.Cookie = 'foo=bar'; const result = func.getHeader(req, 'Cookie'); assert.equal(result, 'foo=bar'); @@ -133,7 +104,7 @@ describe('HTTPModuleFunctions', async () => { 'x-slack-request-timestamp': timestamp, }, } as unknown as BufferedIncomingMessage; - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const res = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; const result = await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); assert.isDefined(result.rawBody); }); @@ -156,7 +127,11 @@ describe('HTTPModuleFunctions', async () => { try { await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale'); + assert.propertyVal( + e, + 'message', + 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale', + ); } }); it('should detect an invalid signature', async () => { @@ -175,7 +150,7 @@ describe('HTTPModuleFunctions', async () => { try { await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: signature mismatch'); + assert.propertyVal(e, 'message', 'Failed to verify authenticity: signature mismatch'); } }); it('should parse a ssl_check request body without signature verification', async () => { @@ -207,7 +182,7 @@ describe('HTTPModuleFunctions', async () => { try { await func.parseAndVerifyHTTPRequest({ signingSecret }, req, res); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: signature mismatch'); + assert.propertyVal(e, 'message', 'Failed to verify authenticity: signature mismatch'); } }); }); @@ -215,32 +190,26 @@ describe('HTTPModuleFunctions', async () => { describe('HTTP response builder methods', async () => { it('should have buildContentResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildContentResponse(res, 'OK'); - assert.isTrue(writeHead.calledWith(200)); + const res = sinon.createStubInstance(ServerResponse); + func.buildContentResponse(res as unknown as ServerResponse, 'OK'); + assert.isTrue(res.writeHead.calledWith(200)); }); it('should have buildNoBodyResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildNoBodyResponse(res, 500); - assert.isTrue(writeHead.calledWith(500)); + const res = sinon.createStubInstance(ServerResponse); + func.buildNoBodyResponse(res as unknown as ServerResponse, 500); + assert.isTrue(res.writeHead.calledWith(500)); }); it('should have buildSSLCheckResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildSSLCheckResponse(res); - assert.isTrue(writeHead.calledWith(200)); + const res = sinon.createStubInstance(ServerResponse); + func.buildSSLCheckResponse(res as unknown as ServerResponse); + assert.isTrue(res.writeHead.calledWith(200)); }); it('should have buildUrlVerificationResponse', async () => { - const res: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - res.writeHead = writeHead; - func.buildUrlVerificationResponse(res, { challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P' }); - assert.isTrue(writeHead.calledWith(200)); + const res = sinon.createStubInstance(ServerResponse); + func.buildUrlVerificationResponse(res as unknown as ServerResponse, { + challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', + }); + assert.isTrue(res.writeHead.calledWith(200)); }); }); @@ -250,75 +219,65 @@ describe('HTTPModuleFunctions', async () => { describe('defaultDispatchErrorHandler', async () => { it('should properly handle ReceiverMultipleAckError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultDispatchErrorHandler({ error: new ReceiverMultipleAckError(), logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(500)); + assert.isTrue(response.writeHead.calledWith(500)); }); it('should properly handle HTTPReceiverDeferredRequestError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultDispatchErrorHandler({ - error: new HTTPReceiverDeferredRequestError('msg', request, response), + error: new HTTPReceiverDeferredRequestError('msg', request, response as unknown as ServerResponse), logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(404)); + assert.isTrue(response.writeHead.calledWith(404)); }); }); describe('defaultProcessEventErrorHandler', async () => { it('should properly handle ReceiverMultipleAckError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultProcessEventErrorHandler({ error: new ReceiverMultipleAckError(), storedResponse: undefined, logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(500)); + assert.isTrue(response.writeHead.calledWith(500)); }); it('should properly handle AuthorizationError', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultProcessEventErrorHandler({ error: new AuthorizationError('msg', new Error()), storedResponse: undefined, logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(401)); + assert.isTrue(response.writeHead.calledWith(401)); }); }); describe('defaultUnhandledRequestHandler', async () => { it('should properly execute', async () => { const request = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const response: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - response.writeHead = writeHead; + const response = sinon.createStubInstance(ServerResponse); func.defaultUnhandledRequestHandler({ logger, request, - response, + response: response as unknown as ServerResponse, }); - assert.isTrue(writeHead.calledWith(404)); + assert.isTrue(response.writeHead.calledWith(404)); }); }); }); diff --git a/src/receivers/HTTPReceiver.spec.ts b/test/unit/receivers/HTTPReceiver.spec.ts similarity index 60% rename from src/receivers/HTTPReceiver.spec.ts rename to test/unit/receivers/HTTPReceiver.spec.ts index 6fc766435..dad1a3b18 100644 --- a/src/receivers/HTTPReceiver.spec.ts +++ b/test/unit/receivers/HTTPReceiver.spec.ts @@ -1,113 +1,56 @@ -import 'mocha'; -import { EventEmitter } from 'events'; -import { IncomingMessage, ServerResponse } from 'http'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Logger, LogLevel } from '@slack/logger'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { InstallProvider } from '@slack/oauth'; +import { assert } from 'chai'; +import type { ParamsDictionary } from 'express-serve-static-core'; import { match } from 'path-to-regexp'; -import { ParamsDictionary } from 'express-serve-static-core'; -import { Override, mergeOverrides } from '../test-helpers'; +import rewiremock from 'rewiremock'; +import sinon from 'sinon'; import { AppInitializationError, CustomRouteInitializationError, HTTPReceiverDeferredRequestError, -} from '../errors'; - -/* Testing Harness */ +} from '../../../src/errors'; +import type { CustomRoute } from '../../../src/receivers/custom-routes'; +import { + FakeServer, + type Override, + createFakeLogger, + mergeOverrides, + type noopVoid, + withHttpCreateServer, + withHttpsCreateServer, +} from '../helpers'; // Loading the system under test using overrides -async function importHTTPReceiver(overrides: Override = {}): Promise { - return (await rewiremock.module(() => import('./HTTPReceiver'), overrides)).default; -} - -// Composable overrides -function withHttpCreateServer(spy: SinonSpy): Override { - return { - http: { - createServer: spy, - }, - }; -} - -function withHttpsCreateServer(spy: SinonSpy): Override { - return { - https: { - createServer: spy, - }, - }; -} - -// Fakes -class FakeServer extends EventEmitter { - public on = sinon.fake(); - - public listen = sinon.fake((_listenOptions: any, cb: any) => { - if (this.listeningFailure !== undefined) { - this.emit('error', this.listeningFailure); - } - cb(); - }); - - public close = sinon.fake((...args: any[]) => { - setImmediate(() => { - this.emit('close'); - setImmediate(() => { - args[0](); - }); - }); - }); - - public constructor(private listeningFailure?: Error) { - super(); - } +async function importHTTPReceiver( + overrides: Override = {}, +): Promise { + return (await rewiremock.module(() => import('../../../src/receivers/HTTPReceiver'), overrides)).default; } -describe('HTTPReceiver', function () { - beforeEach(function () { - this.listener = (_req: any, _res: any) => {}; - this.fakeServer = new FakeServer(); - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - this.fakeCreateServer = sinon.fake(function (_: any, handler: (req: any, res: any) => void) { - that.listener = handler; // pick up the socket listener method so we can assert on its behaviour - return that.fakeServer as FakeServer; +describe('HTTPReceiver', () => { + // TODO: we pick up the socket listener method so we can assert on its behaviour; probably should add tests for it then + // let httpRequestListener: typeof noopVoid; + let fakeServer: FakeServer; + let fakeCreateServer: sinon.SinonSpy; + const noopLogger = createFakeLogger(); + let overrides: Override; + beforeEach(() => { + fakeServer = new FakeServer(); + fakeCreateServer = sinon.fake((_options: Record, _handler: typeof noopVoid) => { + // TODO: we pick up the socket listener method so we can assert on its behaviour; probably should add tests for it then + // httpRequestListener = _handler; + return fakeServer; }); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); }); - const noopLogger: Logger = { - debug(..._msg: any[]): void { - /* noop */ - }, - info(..._msg: any[]): void { - /* noop */ - }, - warn(..._msg: any[]): void { - /* noop */ - }, - error(..._msg: any[]): void { - /* noop */ - }, - setLevel(_level: LogLevel): void { - /* noop */ - }, - getLevel(): LogLevel { - return LogLevel.DEBUG; - }, - setName(_name: string): void { - /* noop */ - }, - }; - - describe('constructor', function () { + describe('constructor', () => { // NOTE: it would be more informative to test known valid combinations of options, as well as invalid combinations - it('should accept supported arguments and use default arguments when not provided', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should accept supported arguments and use default arguments when not provided', async () => { const HTTPReceiver = await importHTTPReceiver(overrides); const receiver = new HTTPReceiver({ @@ -146,26 +89,21 @@ describe('HTTPReceiver', function () { assert.isNotNull(receiver); }); - it('should accept a custom port', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should accept a custom port', async () => { const HTTPReceiver = await importHTTPReceiver(overrides); const defaultPort = new HTTPReceiver({ signingSecret: 'secret', }); assert.isNotNull(defaultPort); - assert.equal((defaultPort as any).port, 3000); + assert.propertyVal(defaultPort, 'port', 3000); const customPort = new HTTPReceiver({ port: 9999, signingSecret: 'secret', }); assert.isNotNull(customPort); - assert.equal((customPort as any).port, 9999); + assert.propertyVal(customPort, 'port', 9999); const customPort2 = new HTTPReceiver({ port: 7777, @@ -175,10 +113,10 @@ describe('HTTPReceiver', function () { }, }); assert.isNotNull(customPort2); - assert.equal((customPort2 as any).port, 9999); + assert.propertyVal(customPort2, 'port', 9999); }); - it('should throw an error if redirect uri options supplied invalid or incomplete', async function () { + it('should throw an error if redirect uri options supplied invalid or incomplete', async () => { const HTTPReceiver = await importHTTPReceiver(); const clientId = 'my-clientId'; const clientSecret = 'my-clientSecret'; @@ -201,69 +139,72 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); // redirectUri supplied, but missing redirectUriPath - assert.throws(() => new HTTPReceiver({ - clientId, - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - }), AppInitializationError); + assert.throws( + () => + new HTTPReceiver({ + clientId, + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + }), + AppInitializationError, + ); // inconsistent redirectUriPath - assert.throws(() => new HTTPReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri, - installerOptions: { - redirectUriPath: '/hiya', - }, - }), AppInitializationError); + assert.throws( + () => + new HTTPReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri, + installerOptions: { + redirectUriPath: '/hiya', + }, + }), + AppInitializationError, + ); // inconsistent redirectUri - assert.throws(() => new HTTPReceiver({ - clientId: 'my-clientId', - clientSecret, - signingSecret, - stateSecret, - scopes, - redirectUri: 'http://example.com/hiya', - installerOptions, - }), AppInitializationError); + assert.throws( + () => + new HTTPReceiver({ + clientId: 'my-clientId', + clientSecret, + signingSecret, + stateSecret, + scopes, + redirectUri: 'http://example.com/hiya', + installerOptions, + }), + AppInitializationError, + ); }); }); - describe('start() method', function () { - it('should accept both numeric and string port arguments and correctly pass as number into server.listen method', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + describe('start() method', () => { + it('should accept both numeric and string port arguments and correctly pass as number into server.listen method', async () => { const HTTPReceiver = await importHTTPReceiver(overrides); const defaultPort = new HTTPReceiver({ signingSecret: 'secret', }); assert.isNotNull(defaultPort); - assert.equal((defaultPort as any).port, 3000); + assert.propertyVal(defaultPort, 'port', 3000); await defaultPort.start(9001); - assert.isTrue(this.fakeServer.listen.calledWith(9001)); + sinon.assert.calledWith(fakeServer.listen, 9001); await defaultPort.stop(); + fakeServer.listen.resetHistory(); await defaultPort.start('1337'); - assert.isTrue(this.fakeServer.listen.calledWith(1337)); + sinon.assert.calledWith(fakeServer.listen, 1337); await defaultPort.stop(); }); }); - describe('request handling', function () { - describe('handleInstallPathRequest()', function () { - it('should invoke installer handleInstallPath if a request comes into the install path', async function () { - // Arrange + describe('request handling', () => { + describe('handleInstallPathRequest()', () => { + it('should invoke installer handleInstallPath if a request comes into the install path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -285,27 +226,16 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/hiya'; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - const setHeader = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - fakeRes.setHeader = setHeader; - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); + const fakeRes = sinon.createStubInstance(ServerResponse); + receiver.requestListener(fakeReq, fakeRes as unknown as ServerResponse); + sinon.assert.calledWith(installProviderStub.handleInstallPath, fakeReq, fakeRes); }); - it('should use a custom HTML renderer for the install path webpage', async function () { - // Arrange + it('should use a custom HTML renderer for the install path webpage', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -332,22 +262,12 @@ describe('HTTPReceiver', function () { fakeReq.url = '/hiya'; fakeReq.method = 'GET'; const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - /* eslint-disable-next-line @typescript-eslint/await-thenable */ - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); + receiver.requestListener(fakeReq, fakeRes); + sinon.assert.calledWith(installProviderStub.handleInstallPath, fakeReq, fakeRes); }); - it('should redirect installers if directInstall is true', async function () { - // Arrange + it('should redirect installers if directInstall is true', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -374,25 +294,16 @@ describe('HTTPReceiver', function () { fakeReq.url = '/hiya'; fakeReq.method = 'GET'; const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); + receiver.requestListener(fakeReq, fakeRes); + sinon.assert.calledWith(installProviderStub.handleInstallPath, fakeReq, fakeRes); }); }); - describe('handleInstallRedirectRequest()', function () { - it('should invoke installer handler if a request comes into the redirect URI path', async function () { - // Arrange + describe('handleInstallRedirectRequest()', () => { + it('should invoke installer handler if a request comes into the redirect URI path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider, { handleCallback: sinon.stub().resolves() as unknown as Promise, }); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -417,28 +328,18 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/heyo'; fakeReq.method = 'GET'; const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - const writeHead = sinon.fake(); - const end = sinon.fake(); - fakeRes.writeHead = writeHead; - fakeRes.end = end; - /* eslint-disable-next-line @typescript-eslint/await-thenable */ - await receiver.requestListener(fakeReq, fakeRes); - assert(installProviderStub.handleCallback.calledWith(fakeReq, fakeRes, callbackOptions)); + receiver.requestListener(fakeReq, fakeRes); + sinon.assert.calledWith(installProviderStub.handleCallback, fakeReq, fakeRes, callbackOptions); }); - it('should invoke installer handler with installURLoptions supplied if state verification is off', async function () { - // Arrange + it('should invoke installer handler with installURLoptions supplied if state verification is off', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider, { handleCallback: sinon.stub().resolves() as unknown as Promise, }); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -471,20 +372,22 @@ describe('HTTPReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/heyo'; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - fakeRes.writeHead = sinon.fake(); - fakeRes.end = sinon.fake(); - await receiver.requestListener(fakeReq, fakeRes); + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + receiver.requestListener(fakeReq, fakeRes); sinon.assert.calledWith( - installProviderStub.handleCallback, fakeReq, fakeRes, callbackOptions, installUrlOptions, + installProviderStub.handleCallback, + fakeReq, + fakeRes, + callbackOptions, + installUrlOptions, ); }); }); - describe('custom route handling', async function () { - it('should call custom route handler only if request matches route path and method', async function () { + describe('custom route handling', async () => { + it('should call custom route handler only if request matches route path and method', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -500,23 +403,23 @@ describe('HTTPReceiver', function () { fakeReq.url = '/test'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches route path and method, ignoring query params', async function () { + it('should call custom route handler only if request matches route path and method, ignoring query params', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -526,29 +429,29 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test?hello=world'; const tempMatch = matchRegex('/test'); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches route path and method including params', async function () { + it('should call custom route handler only if request matches route path and method including params', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -558,29 +461,29 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches multiple route paths and method including params', async function () { + it('should call custom route handler only if request matches multiple route paths and method including params', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [ { path: '/test/123', method: ['get', 'POST'], handler: sinon.fake() }, @@ -593,30 +496,30 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); - assert(customRoutes[1].handler.notCalled); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); + sinon.assert.notCalled(customRoutes[1].handler); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async function () { + it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async () => { const HTTPReceiver = await importHTTPReceiver(); const customRoutes = [ { path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }, @@ -629,48 +532,47 @@ describe('HTTPReceiver', function () { customRoutes, }); - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.method = 'GET'; receiver.requestListener(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); - assert(customRoutes[1].handler.notCalled); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); + sinon.assert.notCalled(customRoutes[1].handler); fakeReq.method = 'POST'; receiver.requestListener(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); - assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); + sinon.assert.calledWith(customRoutes[0].handler, expectedMessage, fakeRes); fakeReq.method = 'UNHANDLED_METHOD'; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); - it("should throw an error if customRoutes don't have the required keys", async function () { + it("should throw an error if customRoutes don't have the required keys", async () => { const HTTPReceiver = await importHTTPReceiver(); - const customRoutes = [{ path: '/test' }] as any; - - assert.throws(() => new HTTPReceiver({ - clientSecret: 'my-client-secret', - signingSecret: 'secret', - customRoutes, - }), CustomRouteInitializationError); + const customRoutes = [{ path: '/test' }] as CustomRoute[]; + + assert.throws( + () => + new HTTPReceiver({ + clientSecret: 'my-client-secret', + signingSecret: 'secret', + customRoutes, + }), + CustomRouteInitializationError, + ); }); }); - it("should throw if request doesn't match install path, redirect URI path, or custom routes", async function () { - // Arrange + it("should throw if request doesn't match install path, redirect URI path, or custom routes", async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const HTTPReceiver = await importHTTPReceiver(overrides); const metadata = 'this is bat country'; @@ -699,13 +601,11 @@ describe('HTTPReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage) as IncomingMessage; fakeReq.url = '/nope'; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - fakeRes.writeHead = sinon.fake(); - fakeRes.end = sinon.fake(); + const fakeRes = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; assert.throws(() => receiver.requestListener(fakeReq, fakeRes), HTTPReceiverDeferredRequestError); }); diff --git a/src/receivers/HTTPResponseAck.spec.ts b/test/unit/receivers/HTTPResponseAck.spec.ts similarity index 87% rename from src/receivers/HTTPResponseAck.spec.ts rename to test/unit/receivers/HTTPResponseAck.spec.ts index b3e7e06b1..3c40616ac 100644 --- a/src/receivers/HTTPResponseAck.spec.ts +++ b/test/unit/receivers/HTTPResponseAck.spec.ts @@ -1,11 +1,10 @@ -import 'mocha'; -import { IncomingMessage, ServerResponse } from 'http'; -import sinon from 'sinon'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { assert } from 'chai'; -import { HTTPResponseAck } from './HTTPResponseAck'; -import { HTTPModuleFunctions } from './HTTPModuleFunctions'; -import { ReceiverMultipleAckError } from '../errors'; -import { createFakeLogger } from '../test-helpers'; +import sinon from 'sinon'; +import { ReceiverMultipleAckError } from '../../../src/errors'; +import * as HTTPModuleFunctions from '../../../src/receivers/HTTPModuleFunctions'; +import { HTTPResponseAck } from '../../../src/receivers/HTTPResponseAck'; +import { createFakeLogger } from '../helpers'; describe('HTTPResponseAck', async () => { it('should work', async () => { @@ -25,7 +24,6 @@ describe('HTTPResponseAck', async () => { const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage; const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; const spy = sinon.spy(); - // eslint-disable-next-line no-new new HTTPResponseAck({ logger: createFakeLogger(), processBeforeResponse: false, @@ -43,7 +41,6 @@ describe('HTTPResponseAck', async () => { const httpRequest = sinon.createStubInstance(IncomingMessage) as IncomingMessage; const httpResponse: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; const spy = sinon.spy(); - // eslint-disable-next-line no-new const responseAck = new HTTPResponseAck({ logger: createFakeLogger(), processBeforeResponse: false, @@ -102,7 +99,11 @@ describe('HTTPResponseAck', async () => { const bound = ack.bind(); const body = false; await bound(body); - assert.equal(ack.storedResponse, '', 'Falsy body passed to bound handler not stored as empty string in Ack instance.'); + assert.equal( + ack.storedResponse, + '', + 'Falsy body passed to bound handler not stored as empty string in Ack instance.', + ); }); it('should call buildContentResponse with response body if processBeforeResponse=false', async () => { const stub = sinon.stub(HTTPModuleFunctions, 'buildContentResponse'); @@ -117,6 +118,9 @@ describe('HTTPResponseAck', async () => { const bound = ack.bind(); const body = { some: 'thing' }; await bound(body); - assert(stub.calledWith(httpResponse, body), 'buildContentResponse called with HTTP Response object and response body.'); + assert( + stub.calledWith(httpResponse, body), + 'buildContentResponse called with HTTP Response object and response body.', + ); }); }); diff --git a/src/receivers/SocketModeFunctions.spec.ts b/test/unit/receivers/SocketModeFunctions.spec.ts similarity index 68% rename from src/receivers/SocketModeFunctions.spec.ts rename to test/unit/receivers/SocketModeFunctions.spec.ts index e0516a3d8..fe68da142 100644 --- a/src/receivers/SocketModeFunctions.spec.ts +++ b/test/unit/receivers/SocketModeFunctions.spec.ts @@ -1,12 +1,8 @@ -import 'mocha'; import { assert } from 'chai'; -import { SocketModeFunctions as func } from './SocketModeFunctions'; -import { - ReceiverMultipleAckError, - AuthorizationError, -} from '../errors'; -import { createFakeLogger } from '../test-helpers'; -import { ReceiverEvent } from '../types'; +import { AuthorizationError, ReceiverMultipleAckError } from '../../../src/errors'; +import { defaultProcessEventErrorHandler } from '../../../src/receivers/SocketModeFunctions'; +import type { ReceiverEvent } from '../../../src/types'; +import { createFakeLogger } from '../helpers'; describe('SocketModeFunctions', async () => { describe('Error handlers for event processing', async () => { @@ -18,7 +14,7 @@ describe('SocketModeFunctions', async () => { ack: async () => {}, body: {}, }; - const shouldBeAcked = await func.defaultProcessEventErrorHandler({ + const shouldBeAcked = await defaultProcessEventErrorHandler({ error: new ReceiverMultipleAckError(), logger, event, @@ -30,7 +26,7 @@ describe('SocketModeFunctions', async () => { ack: async () => {}, body: {}, }; - const shouldBeAcked = await func.defaultProcessEventErrorHandler({ + const shouldBeAcked = await defaultProcessEventErrorHandler({ error: new AuthorizationError('msg', new Error()), logger, event, diff --git a/src/receivers/SocketModeReceiver.spec.ts b/test/unit/receivers/SocketModeReceiver.spec.ts similarity index 64% rename from src/receivers/SocketModeReceiver.spec.ts rename to test/unit/receivers/SocketModeReceiver.spec.ts index 8b02d9a72..ffdca6997 100644 --- a/src/receivers/SocketModeReceiver.spec.ts +++ b/test/unit/receivers/SocketModeReceiver.spec.ts @@ -1,85 +1,50 @@ -import 'mocha'; -import { EventEmitter } from 'events'; -import { IncomingMessage, ServerResponse } from 'http'; -import sinon, { SinonSpy } from 'sinon'; -import { assert } from 'chai'; -import rewiremock from 'rewiremock'; -import { Logger, LogLevel } from '@slack/logger'; -import { match } from 'path-to-regexp'; -import { ParamsDictionary } from 'express-serve-static-core'; +import { IncomingMessage, ServerResponse } from 'node:http'; import { InstallProvider } from '@slack/oauth'; import { SocketModeClient } from '@slack/socket-mode'; -import { Override, mergeOverrides } from '../test-helpers'; -import { CustomRouteInitializationError, AppInitializationError } from '../errors'; - -// Fakes -class FakeServer extends EventEmitter { - public on = sinon.fake(); - - public listen = sinon.fake(() => { - if (this.listeningFailure !== undefined) { - this.emit('error', this.listeningFailure); - } - }); - - public close = sinon.fake((...args: any[]) => { - setImmediate(() => { - this.emit('close'); - setImmediate(() => { - args[0](); - }); - }); - }); +import { assert } from 'chai'; +import type { ParamsDictionary } from 'express-serve-static-core'; +import { match } from 'path-to-regexp'; +import rewiremock from 'rewiremock'; +import sinon from 'sinon'; +import { AppInitializationError, CustomRouteInitializationError } from '../../../src/errors'; +import { + FakeServer, + type Override, + createFakeLogger, + mergeOverrides, + type noopVoid, + withHttpCreateServer, + withHttpsCreateServer, +} from '../helpers'; - public constructor(private listeningFailure?: Error) { - super(); - } +// Loading the system under test using overrides +async function importSocketModeReceiver( + overrides: Override = {}, +): Promise { + return (await rewiremock.module(() => import('../../../src/receivers/SocketModeReceiver'), overrides)).default; } -describe('SocketModeReceiver', function () { - beforeEach(function () { - this.listener = (_req: any, _res: any) => {}; - this.fakeServer = new FakeServer(); - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - this.fakeCreateServer = sinon.fake(function (handler: (req: any, res: any) => void) { - that.listener = handler; // pick up the socket listener method so we can assert on its behaviour - return that.fakeServer as FakeServer; +describe('SocketModeReceiver', () => { + let socketModeHttpServerHandler: typeof noopVoid; + let fakeServer: FakeServer; + let fakeCreateServer: sinon.SinonSpy; + const noopLogger = createFakeLogger(); + let overrides: Override; + beforeEach(() => { + fakeServer = new FakeServer(); + fakeCreateServer = sinon.fake((handler: typeof noopVoid) => { + socketModeHttpServerHandler = handler; // pick up the socket-mode receiver's HTTP request handler so we can assert on its behaviour + return fakeServer; }); + overrides = mergeOverrides( + withHttpCreateServer(fakeCreateServer), + withHttpsCreateServer(sinon.fake.throws('Should not be used.')), + ); }); - const noopLogger: Logger = { - debug(..._msg: any[]): void { - /* noop */ - }, - info(..._msg: any[]): void { - /* noop */ - }, - warn(..._msg: any[]): void { - /* noop */ - }, - error(..._msg: any[]): void { - /* noop */ - }, - setLevel(_level: LogLevel): void { - /* noop */ - }, - getLevel(): LogLevel { - return LogLevel.DEBUG; - }, - setName(_name: string): void { - /* noop */ - }, - }; - - describe('constructor', function () { + describe('constructor', () => { // NOTE: it would be more informative to test known valid combinations of options, as well as invalid combinations - it('should accept supported arguments and use default arguments when not provided', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should accept supported arguments and use default arguments when not provided', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -95,15 +60,8 @@ describe('SocketModeReceiver', function () { }, }); assert.isNotNull(receiver); - // since v3.8, the constructor does not start the server - // assert.isNotOk(this.fakeServer.listen.calledWith(3000)); }); - it('should allow for customizing port the socket listens on', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should allow for customizing port the socket listens on', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const customPort = 1337; @@ -121,15 +79,8 @@ describe('SocketModeReceiver', function () { }, }); assert.isNotNull(receiver); - // since v3.8, the constructor does not start the server - // assert.isOk(this.fakeServer.listen.calledWith(customPort)); }); - it('should allow for extracting additional values from Socket Mode messages', async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should allow for extracting additional values from Socket Mode messages', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -139,11 +90,7 @@ describe('SocketModeReceiver', function () { }); assert.isNotNull(receiver); }); - it('should throw an error if redirect uri options supplied invalid or incomplete', async function () { - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it('should throw an error if redirect uri options supplied invalid or incomplete', async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); const clientId = 'my-clientId'; const clientSecret = 'my-clientSecret'; @@ -166,47 +113,89 @@ describe('SocketModeReceiver', function () { }); assert.isNotNull(receiver); // redirectUri supplied, but no redirectUriPath - assert.throws(() => new SocketModeReceiver({ - appToken, - clientId, - clientSecret, - stateSecret, - scopes, - redirectUri, - }), AppInitializationError); + assert.throws( + () => + new SocketModeReceiver({ + appToken, + clientId, + clientSecret, + stateSecret, + scopes, + redirectUri, + }), + AppInitializationError, + ); // inconsistent redirectUriPath - assert.throws(() => new SocketModeReceiver({ - appToken, + assert.throws( + () => + new SocketModeReceiver({ + appToken, + clientId: 'my-clientId', + clientSecret, + stateSecret, + scopes, + redirectUri, + installerOptions: { + redirectUriPath: '/hiya', + }, + }), + AppInitializationError, + ); + // inconsistent redirectUri + assert.throws( + () => + new SocketModeReceiver({ + appToken, + clientId: 'my-clientId', + clientSecret, + stateSecret, + scopes, + redirectUri: 'http://example.com/hiya', + installerOptions, + }), + AppInitializationError, + ); + }); + }); + describe('request handling', () => { + it('should return a 404 if a request flows through the install path, redirect URI path and custom routes without being handled', async () => { + const installProviderStub = sinon.createStubInstance(InstallProvider); + const SocketModeReceiver = await importSocketModeReceiver(overrides); + + const metadata = 'this is bat country'; + const scopes = ['channels:read']; + const userScopes = ['chat:write']; + const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; + const receiver = new SocketModeReceiver({ + appToken: 'my-secret', + logger: noopLogger, clientId: 'my-clientId', - clientSecret, - stateSecret, + clientSecret: 'my-client-secret', + stateSecret: 'state-secret', scopes, - redirectUri, + customRoutes, + redirectUri: 'http://example.com/heyo', installerOptions: { - redirectUriPath: '/hiya', + authVersion: 'v2', + installPath: '/hiya', + redirectUriPath: '/heyo', + metadata, + userScopes, }, - }), AppInitializationError); - // inconsistent redirectUri - assert.throws(() => new SocketModeReceiver({ - appToken, - clientId: 'my-clientId', - clientSecret, - stateSecret, - scopes, - redirectUri: 'http://example.com/hiya', - installerOptions, - }), AppInitializationError); + }); + assert.isNotNull(receiver); + receiver.installer = installProviderStub as unknown as InstallProvider; + const fakeReq = sinon.createStubInstance(IncomingMessage); + fakeReq.url = '/nope'; + fakeReq.method = 'GET'; + const fakeRes = sinon.createStubInstance(ServerResponse); + await socketModeHttpServerHandler(fakeReq, fakeRes); + sinon.assert.calledWith(fakeRes.writeHead, 404, sinon.match.object); + assert(fakeRes.end.calledOnce); }); - }); - describe('request handling', function () { - describe('handleInstallPathRequest()', function () { - it('should invoke installer handleInstallPath if a request comes into the install path', async function () { - // Arrange + describe('handleInstallPathRequest()', () => { + it('should invoke installer handleInstallPath if a request comes into the install path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; @@ -236,16 +225,11 @@ describe('SocketModeReceiver', function () { writeHead: sinon.fake(), end: sinon.fake(), } as unknown as ServerResponse; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); }); - it('should use a custom HTML renderer for the install path webpage', async function () { - // Arrange + it('should use a custom HTML renderer for the install path webpage', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; @@ -276,16 +260,11 @@ describe('SocketModeReceiver', function () { writeHead: sinon.fake(), end: sinon.fake(), } as unknown as ServerResponse; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); }); - it('should redirect installers if directInstall is true', async function () { - // Arrange + it('should redirect installers if directInstall is true', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; @@ -316,18 +295,13 @@ describe('SocketModeReceiver', function () { writeHead: sinon.fake(), end: sinon.fake(), } as unknown as ServerResponse; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(installProviderStub.handleInstallPath.calledWith(fakeReq, fakeRes)); }); }); - describe('handleInstallRedirectRequest()', function () { - it('should invoke installer handleCallback if a request comes into the redirect URI path', async function () { - // Arrange + describe('handleInstallRedirectRequest()', () => { + it('should invoke installer handleCallback if a request comes into the redirect URI path', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const callbackOptions = { @@ -357,7 +331,7 @@ describe('SocketModeReceiver', function () { method: 'GET', }; const fakeRes = null; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert( installProviderStub.handleCallback.calledWith( fakeReq as IncomingMessage, @@ -366,13 +340,8 @@ describe('SocketModeReceiver', function () { ), ); }); - it('should invoke handleCallback with installURLoptions as params if state verification is off', async function () { - // Arrange + it('should invoke handleCallback with installURLoptions as params if state verification is off', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const metadata = 'this is bat country'; const scopes = ['channels:read']; @@ -407,14 +376,12 @@ describe('SocketModeReceiver', function () { }); assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; + const fakeReq = sinon.createStubInstance(IncomingMessage); fakeReq.url = '/heyo'; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - const fakeRes: ServerResponse = sinon.createStubInstance(ServerResponse) as unknown as ServerResponse; - fakeRes.writeHead = sinon.fake(); - fakeRes.end = sinon.fake(); - await this.listener(fakeReq, fakeRes); + const fakeRes = sinon.createStubInstance(ServerResponse); + await socketModeHttpServerHandler(fakeReq, fakeRes); sinon.assert.calledWith( installProviderStub.handleCallback, fakeReq as IncomingMessage, @@ -424,14 +391,9 @@ describe('SocketModeReceiver', function () { ); }); }); - describe('custom route handling', function () { - it('should call custom route handler only if request matches route path and method', async function () { - // Arrange + describe('custom route handling', () => { + it('should call custom route handler only if request matches route path and method', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -443,38 +405,33 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler when request matches path, ignoring query params', async function () { - // Arrange + it('should call custom route handler when request matches path, ignoring query params', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -486,38 +443,33 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test?hello=world'; const tempMatch = matchRegex('/test'); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler only if request matches route path and method including params', async function () { - // Arrange + it('should call custom route handler only if request matches route path and method including params', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [{ path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }]; const matchRegex = match(customRoutes[0].path, { decode: decodeURIComponent }); @@ -529,38 +481,33 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, { params }); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler only if request matches multiple route paths and method including params', async function () { - // Arrange + it('should call custom route handler only if request matches multiple route paths and method including params', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [ { path: '/test/123', method: ['get', 'POST'], handler: sinon.fake() }, @@ -575,40 +522,35 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); assert(customRoutes[1].handler.notCalled); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); assert(customRoutes[1].handler.notCalled); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async function () { - // Arrange + it('should call custom route handler only if request matches multiple route paths and method including params reverse order', async () => { const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const customRoutes = [ { path: '/test/:id', method: ['get', 'POST'], handler: sinon.fake() }, @@ -623,99 +565,48 @@ describe('SocketModeReceiver', function () { assert.isNotNull(receiver); receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq: IncomingMessage = sinon.createStubInstance(IncomingMessage) as IncomingMessage; - const fakeRes = { writeHead: sinon.fake(), end: sinon.fake() }; + const fakeReq = sinon.createStubInstance(IncomingMessage); + const fakeRes = sinon.createStubInstance(ServerResponse); fakeReq.url = '/test/123'; const tempMatch = matchRegex(fakeReq.url); if (!tempMatch) throw new Error('match failed'); - const params : ParamsDictionary = tempMatch.params as ParamsDictionary; + const params: ParamsDictionary = tempMatch.params as ParamsDictionary; fakeReq.headers = { host: 'localhost' }; fakeReq.method = 'GET'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); let expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); assert(customRoutes[1].handler.notCalled); fakeReq.method = 'POST'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); expectedMessage = Object.assign(fakeReq, params); assert(customRoutes[0].handler.calledWith(expectedMessage, fakeRes)); fakeReq.method = 'UNHANDLED_METHOD'; - await this.listener(fakeReq, fakeRes); + await socketModeHttpServerHandler(fakeReq, fakeRes); assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); assert(fakeRes.end.called); }); - it("should throw an error if customRoutes don't have the required keys", async function () { - // Arrange - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); + it("should throw an error if customRoutes don't have the required keys", async () => { const SocketModeReceiver = await importSocketModeReceiver(overrides); + // biome-ignore lint/suspicious/noExplicitAny: typing as any to intentionally have missing required keys const customRoutes = [{ handler: sinon.fake() }] as any; - assert.throws(() => new SocketModeReceiver({ appToken: 'my-secret', customRoutes }), CustomRouteInitializationError); - }); - }); - - it('should return a 404 if a request passes the install path, redirect URI path and custom routes', async function () { - // Arrange - const installProviderStub = sinon.createStubInstance(InstallProvider); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); - const SocketModeReceiver = await importSocketModeReceiver(overrides); - - const metadata = 'this is bat country'; - const scopes = ['channels:read']; - const userScopes = ['chat:write']; - const customRoutes = [{ path: '/test', method: ['get', 'POST'], handler: sinon.fake() }]; - const receiver = new SocketModeReceiver({ - appToken: 'my-secret', - logger: noopLogger, - clientId: 'my-clientId', - clientSecret: 'my-client-secret', - stateSecret: 'state-secret', - scopes, - customRoutes, - redirectUri: 'http://example.com/heyo', - installerOptions: { - authVersion: 'v2', - installPath: '/hiya', - redirectUriPath: '/heyo', - metadata, - userScopes, - }, + assert.throws( + () => new SocketModeReceiver({ appToken: 'my-secret', customRoutes }), + CustomRouteInitializationError, + ); }); - assert.isNotNull(receiver); - receiver.installer = installProviderStub as unknown as InstallProvider; - const fakeReq = { - url: '/nope', - method: 'GET', - }; - const fakeRes = { - writeHead: sinon.fake(), - end: sinon.fake(), - }; - await this.listener(fakeReq, fakeRes); - assert(fakeRes.writeHead.calledWith(404, sinon.match.object)); - assert(fakeRes.end.calledOnce); }); }); - describe('#start()', function () { - it('should invoke the SocketModeClient start method', async function () { - // Arrange + describe('#start()', () => { + it('should invoke the SocketModeClient start method', async () => { const clientStub = sinon.createStubInstance(SocketModeClient); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -736,14 +627,9 @@ describe('SocketModeReceiver', function () { assert(clientStub.start.called); }); }); - describe('#stop()', function () { - it('should invoke the SocketModeClient disconnect method', async function () { - // Arrange + describe('#stop()', () => { + it('should invoke the SocketModeClient disconnect method', async () => { const clientStub = sinon.createStubInstance(SocketModeClient); - const overrides = mergeOverrides( - withHttpCreateServer(this.fakeCreateServer), - withHttpsCreateServer(sinon.fake.throws('Should not be used.')), - ); const SocketModeReceiver = await importSocketModeReceiver(overrides); const receiver = new SocketModeReceiver({ @@ -765,29 +651,3 @@ describe('SocketModeReceiver', function () { }); }); }); - -/* Testing Harness */ - -// Loading the system under test using overrides -async function importSocketModeReceiver( - overrides: Override = {}, -): Promise { - return (await rewiremock.module(() => import('./SocketModeReceiver'), overrides)).default; -} - -// Composable overrides -function withHttpCreateServer(spy: SinonSpy): Override { - return { - http: { - createServer: spy, - }, - }; -} - -function withHttpsCreateServer(spy: SinonSpy): Override { - return { - https: { - createServer: spy, - }, - }; -} diff --git a/src/receivers/verify-request.spec.ts b/test/unit/receivers/verify-request.spec.ts similarity index 68% rename from src/receivers/verify-request.spec.ts rename to test/unit/receivers/verify-request.spec.ts index 783359e8e..7554abb9c 100644 --- a/src/receivers/verify-request.spec.ts +++ b/test/unit/receivers/verify-request.spec.ts @@ -1,7 +1,6 @@ -import 'mocha'; -import { createHmac } from 'crypto'; +import { createHmac } from 'node:crypto'; import { assert } from 'chai'; -import { isValidSlackRequest, verifySlackRequest } from './verify-request'; +import { isValidSlackRequest, verifySlackRequest } from '../../../src/receivers/verify-request'; describe('Request verification', async () => { const signingSecret = 'secret'; @@ -38,7 +37,11 @@ describe('Request verification', async () => { body: rawBody, }); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale'); + assert.propertyVal( + e, + 'message', + 'Failed to verify authenticity: x-slack-request-timestamp must differ from system time by no more than 5 minutes or request is stale', + ); } }); it('should detect an invalid signature', async () => { @@ -54,7 +57,7 @@ describe('Request verification', async () => { body: rawBody, }); } catch (e) { - assert.equal((e as any).message, 'Failed to verify authenticity: signature mismatch'); + assert.propertyVal(e, 'message', 'Failed to verify authenticity: signature mismatch'); } }); }); @@ -66,14 +69,16 @@ describe('Request verification', async () => { const hmac = createHmac('sha256', signingSecret); hmac.update(`v0:${timestamp}:${rawBody}`); const signature = hmac.digest('hex'); - assert.isTrue(isValidSlackRequest({ - signingSecret, - headers: { - 'x-slack-signature': `v0=${signature}`, - 'x-slack-request-timestamp': timestamp, - }, - body: rawBody, - })); + assert.isTrue( + isValidSlackRequest({ + signingSecret, + headers: { + 'x-slack-signature': `v0=${signature}`, + 'x-slack-request-timestamp': timestamp, + }, + body: rawBody, + }), + ); }); it('should detect an invalid timestamp', async () => { const timestamp = Math.floor(Date.now() / 1000) - 600; // 10 minutes @@ -81,26 +86,30 @@ describe('Request verification', async () => { const hmac = createHmac('sha256', signingSecret); hmac.update(`v0:${timestamp}:${rawBody}`); const signature = hmac.digest('hex'); - assert.isFalse(isValidSlackRequest({ - signingSecret, - headers: { - 'x-slack-signature': `v0=${signature}`, - 'x-slack-request-timestamp': timestamp, - }, - body: rawBody, - })); + assert.isFalse( + isValidSlackRequest({ + signingSecret, + headers: { + 'x-slack-signature': `v0=${signature}`, + 'x-slack-request-timestamp': timestamp, + }, + body: rawBody, + }), + ); }); it('should detect an invalid signature', async () => { const timestamp = Math.floor(Date.now() / 1000); const rawBody = '{"foo":"bar"}'; - assert.isFalse(isValidSlackRequest({ - signingSecret, - headers: { - 'x-slack-signature': 'v0=invalid-signature', - 'x-slack-request-timestamp': timestamp, - }, - body: rawBody, - })); + assert.isFalse( + isValidSlackRequest({ + signingSecret, + headers: { + 'x-slack-signature': 'v0=invalid-signature', + 'x-slack-request-timestamp': timestamp, + }, + body: rawBody, + }), + ); }); }); }); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json deleted file mode 100644 index d554c4b36..000000000 --- a/tsconfig.eslint.json +++ /dev/null @@ -1,26 +0,0 @@ -// This config is only used to allow ESLint to use a different include / exclude setting than the actual build -{ - // extend the build config to share compilerOptions - "extends": "./tsconfig.json", - "compilerOptions": { - // Setting "noEmit" prevents misuses of this config such as using it to produce a build - "noEmit": true - }, - "include": [ - // Since extending a config overwrites the entire value for "include", those value are copied here - "src/**/*", - - // List files that should be linted by ESLint, but are not part of the tsconfig used for the actual build - ".eslintrc.js", - "docs/**/*", - "examples/**/*", - "types-tests/**/*" - ], - "exclude": [ - // Overwrite exclude from the base config to clear the value - - // Contains external module type definitions, which are not subject to this project's style rules - "types/**/*", - // Contain intentional type checking issues for the purpose of testing the typechecker's output - ] -} diff --git a/tsconfig.json b/tsconfig.json index f4f2751e1..d4105a9ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,31 +1,22 @@ { "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node18/tsconfig.json", "compilerOptions": { - "skipLibCheck": true, - "lib": ["ES2018"], - "target": "ES2018", - "module": "commonjs", "declaration": true, "declarationMap": true, - "sourceMap": true, - "outDir": "dist", - "strict": true, + "module": "CommonJS", + "moduleResolution": "node", + "noErrorTruncation": true, "noUnusedLocals": true, "noUnusedParameters": true, "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "moduleResolution": "node", - "baseUrl": ".", - "paths": { - "*": ["./types/*"] - }, - "esModuleInterop": true + "outDir": "dist", + "sourceMap": true }, "include": [ "src/**/*" ], "exclude": [ - "**/*.spec.ts", - "src/test-helpers.ts" + "test/**/*" ] } diff --git a/types-tests/action.test-d.ts b/types-tests/action.test-d.ts deleted file mode 100644 index 026e51666..000000000 --- a/types-tests/action.test-d.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expectError, expectType } from 'tsd'; -import { App, BlockElementAction, InteractiveAction, DialogSubmitAction } from '../'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// calling action method with incorrect an type constraint value should not work -expectError(app.action({ type: 'Something wrong' }, async ({ action }) => { - await Promise.resolve(action); -})); - -expectType(app.action({ type: 'block_actions' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); -})); - -expectType(app.action({ type: 'interactive_message' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); -})); - -expectType(app.action({ type: 'dialog_submission' }, async ({ action }) => { - expectType(action); - await Promise.resolve(action); -})); diff --git a/types-tests/command.test-d.ts b/types-tests/command.test-d.ts deleted file mode 100644 index cfe8afa44..000000000 --- a/types-tests/command.test-d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { expectType } from 'tsd'; -import { App, SlashCommand } from '../dist'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -expectType(app.command('/hello', async ({ command }) => { - expectType(command); - await Promise.resolve(command); -})); diff --git a/types-tests/event.test-d.ts b/types-tests/event.test-d.ts deleted file mode 100644 index 06a27ab8f..000000000 --- a/types-tests/event.test-d.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { expectNotType, expectType } from 'tsd'; -import { App, SlackEvent, AppMentionEvent, ReactionAddedEvent, ReactionRemovedEvent, UserHuddleChangedEvent, UserProfileChangedEvent, UserStatusChangedEvent, PinAddedEvent, SayFn, PinRemovedEvent } from '..'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -expectType( - app.event('app_mention', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_added', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_removed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('user_huddle_changed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('user_profile_changed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('user_status_changed', async ({ event }) => { - expectType(event); - expectNotType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('pin_added', async ({ say, event }) => { - expectType(say); - expectType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('pin_removed', async ({ say, event }) => { - expectType(say); - expectType(event); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_added', async ({ say, event }) => { - expectType(say); - await Promise.resolve(event); - }), -); - -expectType( - app.event('reaction_removed', async ({ say, event }) => { - expectType(say); - await Promise.resolve(event); - }), -); diff --git a/types-tests/message.test-d.ts b/types-tests/message.test-d.ts deleted file mode 100644 index 65b24e9c1..000000000 --- a/types-tests/message.test-d.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expectNotType, expectType, expectError } from 'tsd'; -import { App, MessageEvent, GenericMessageEvent, BotMessageEvent, MessageRepliedEvent, MeMessageEvent, MessageDeletedEvent, ThreadBroadcastMessageEvent, MessageChangedEvent, EKMAccessDeniedMessageEvent } from '..'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -expectType( - // TODO: Resolve the event type when having subtype in a listener constraint - // app.message({pattern: 'foo', subtype: 'message_replied'}, async ({ message }) => {}); - app.message(async ({ message }) => { - expectType(message); - - message.channel; // the property access should compile - - // The type here is still a union type of all the possible subtyped events. - // Thus, only the fields available for all the types can be resolved outside if/else statements. - expectError(message.user); - - if (message.subtype === undefined) { - expectType(message); - expectNotType(message); - message.user; // the property access should compile - message.channel; // the property access should compile - message.team; // the property access should compile - } - if (message.subtype === 'bot_message') { - expectType(message); - expectNotType(message); - message.user; // the property access should compile - message.channel; // the property access should compile - } - if (message.subtype === 'ekm_access_denied') { - expectType(message); - expectNotType(message); - message.user; // the property access should compile - message.channel; // the property access should compile - } - if (message.subtype === 'me_message') { - expectType(message); - expectNotType(message); - message.user; // the property access should compile - message.channel; // the property access should compile - } - if (message.subtype === 'message_replied') { - expectType(message); - expectNotType(message); - message.channel; // the property access should compile - message.message; // the property access should compile - } - if (message.subtype === 'message_changed') { - expectType(message); - expectNotType(message); - message.channel; // the property access should compile - message.message; // the property access should compile - } - if (message.subtype === 'message_deleted') { - expectType(message); - expectNotType(message); - message.channel; // the property access should compile - message.ts; // the property access should compile - } - if (message.subtype === 'thread_broadcast') { - expectType(message); - expectNotType(message); - message.channel; // the property access should compile - message.thread_ts; // the property access should compile - message.ts; // the property access should compile - message.root; // the property access should compile - } - - await Promise.resolve(message); - }), -); diff --git a/types-tests/middleware.test-d.ts b/types-tests/middleware.test-d.ts deleted file mode 100644 index 214ebf260..000000000 --- a/types-tests/middleware.test-d.ts +++ /dev/null @@ -1,19 +0,0 @@ -import App from '../src/App'; -import { onlyViewActions, onlyCommands } from '../src/middleware/builtin'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// https://github.com/slackapi/bolt-js/issues/911 -app.use(async (args) => { - onlyViewActions(args); -}); -app.use(async (args) => { - onlyCommands(args); -}); -app.use(async ({ ack, next }) => { - if (ack) { - await ack(); - return; - } - await next(); -}); diff --git a/types-tests/options.test-d.ts b/types-tests/options.test-d.ts deleted file mode 100644 index a9d273f80..000000000 --- a/types-tests/options.test-d.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { expectType, expectError } from 'tsd'; -import { - App, - SlackOptions, - BlockSuggestion, - InteractiveMessageSuggestion, - DialogSuggestion, -} from '../dist'; -import { Option } from '@slack/types'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -const blockSuggestionOptions: Option[] = [ - { - text: { - type: 'plain_text', - text: 'foo', - }, - value: 'bar', - }, -]; - -// set the default to block_suggestion -expectType( - app.options('action-id-or-callback-id', async ({ options, ack }) => { - expectType(options); - // resolved by StringIndexed - expectType(options.callback_id); - options.block_id; - options.action_id; - // https://github.com/slackapi/bolt-js/issues/720 - await ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); - -// block_suggestion -expectType( - app.options<'block_suggestion'>({ action_id: 'a' }, async ({ options, ack }) => { - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - await ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); -// FIXME: app.options({ type: 'block_suggestion', action_id: 'a' } does not work - -// interactive_message (attachments) -expectType( - app.options<'interactive_message'>({ callback_id: 'a' }, async ({ options, ack }) => { - expectType(options); - ack({ options: blockSuggestionOptions }); - await Promise.resolve(options); - }), -); - -expectType( - app.options({ type: 'interactive_message', callback_id: 'a' }, async ({ options, ack }) => { - // FIXME: the type should be OptionsRequest<'interactive_message'> - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - expectError(ack({ options: blockSuggestionOptions })); - await Promise.resolve(options); - }), -); - -// dialog_suggestion (dialog) -expectType( - app.options<'dialog_suggestion'>({ callback_id: 'a' }, async ({ options, ack }) => { - expectType(options); - // https://github.com/slackapi/bolt-js/issues/720 - expectError(ack({ options: blockSuggestionOptions })); - await Promise.resolve(options); - }), -); -// FIXME: app.options({ type: 'dialog_suggestion', callback_id: 'a' } does not work - -const db = { - get: (_teamId: String) => { return [{ label: 'l', value: 'v' }]; }, -}; - -expectType( - // Taken from https://slack.dev/bolt-js/concepts#options - // Example of responding to an external_select options request - app.options('external_action', async ({ options, ack }) => { - // Get information specific to a team or channel - // (modified to satisfy TS compiler) - const results = options.team != null ? await db.get(options.team.id) : []; - - if (results) { - // (modified to satisfy TS compiler) - let options: Option[] = []; - // Collect information in options array to send in Slack ack response - for (const result of results) { - options.push({ - "text": { - "type": "plain_text", - "text": result.label - }, - "value": result.value - }); - } - - await ack({ - "options": options - }); - } else { - await ack(); - } - }) -); diff --git a/types-tests/shortcut.test-d.ts b/types-tests/shortcut.test-d.ts deleted file mode 100644 index b56aa19f3..000000000 --- a/types-tests/shortcut.test-d.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { expectError, expectType } from 'tsd'; -import { App, GlobalShortcut, MessageShortcut, SayFn } from '../'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// calling shortcut method with incorrect an type constraint value should not work -expectError(app.shortcut({ type: 'Something wrong' }, async ({ shortcut }) => { - await Promise.resolve(shortcut); -})); - -// Shortcut in listener should be - MessageShortcut -expectType(app.shortcut({ type: 'message_action' }, async ({ shortcut }) => { - expectType(shortcut); - await Promise.resolve(shortcut); -})); - -// If shortcut is parameterized with MessageShortcut, shortcut argument in callback should be type MessageShortcut -expectType(app.shortcut({}, async ({ shortcut }) => { - expectType(shortcut); - await Promise.resolve(shortcut); -})); - -expectType(app.shortcut({}, async ({ shortcut, say }) => { - expectType(say); - await Promise.resolve(shortcut); -})); - -// If shortcut is parameterized with MessageShortcut, say argument in callback should be type SayFn -expectType(app.shortcut({}, async ({ shortcut, say }) => { - expectType(say); - await Promise.resolve(shortcut); -})); - -// If shortcut is parameterized with GlobalShortcut, say argument in callback should be type undefined -expectType(app.shortcut({}, async ({ shortcut, say }) => { - expectType(say); - await Promise.resolve(shortcut); -})); diff --git a/types-tests/utilities.test-d.ts b/types-tests/utilities.test-d.ts deleted file mode 100644 index 3bc22e470..000000000 --- a/types-tests/utilities.test-d.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { App, InteractiveButtonClick } from '../'; -import { expectType } from 'tsd'; -import { ChatPostMessageResponse } from '@slack/web-api'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -app.action('my_callback_id', async ({ respond, say }) => { - // Expect respond to work with text - await respond({ text: 'Some text' }); - - // Expect respond to work without text - await respond({ delete_original: true }); - - // Expect say to work with text - const response = await say({ text: 'Some more text' }); - expectType(response); - - // since web-api v6.2, this is not an error anymore - // Expect an error when calling say without text - // expectError(await say({ blocks: [] })); -}); diff --git a/types-tests/view.test-d.ts b/types-tests/view.test-d.ts deleted file mode 100644 index 721e6331f..000000000 --- a/types-tests/view.test-d.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { expectType } from 'tsd'; -import { App, SlackViewAction, ViewOutput } from '..'; - -const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); - -// view_submission -expectType( - app.view('modal-id', async ({ body, view }) => { - // TODO: the body can be more specific (ViewSubmitAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }) -); - -expectType( - app.view({ type: 'view_submission', callback_id: 'modal-id' }, async ({ body, view }) => { - // TODO: the body can be more specific (ViewSubmitAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }) -); - -// view_closed -expectType( - app.view({ type: 'view_closed', callback_id: 'modal-id' }, async ({ body, view }) => { - // TODO: the body can be more specific (ViewClosedAction) here - expectType(body); - expectType(view); - await Promise.resolve(view); - }) -); - -const viewSubmissionPayload: ViewOutput = { - "id": "V111", - "team_id": "T111", - "type": "modal", - "blocks": [ - { - "type": "divider", - "block_id": "+3ht" - } - ], - "private_metadata": "", - "callback_id": "", - "state": { - "values": { - "aPVYH": { - "g/t5": { - "type": "radio_buttons", - "selected_option": null - } - }, - "1pSa": { - "h3R": { - "type": "multi_static_select", - "selected_options": [] - } - }, - "a/Rt": { - "zmPQ": { - "type": "plain_text_input", - "value": null - } - }, - "7/wWO": { - "HdJj": { - "type": "plain_text_input", - "value": "test" - } - } - } - }, - "hash": "1618378109.3ndA0Spf", - "title": { - "type": "plain_text", - "text": "Workplace check-in", - "emoji": true - }, - "clear_on_close": false, - "notify_on_close": false, - "close": { - "type": "plain_text", - "text": "Cancel", - "emoji": true - }, - "submit": { - "type": "plain_text", - "text": "Submit", - "emoji": true - }, - "previous_view_id": null, - "root_view_id": "V1234567890", - "app_id": "A02", - "external_id": "", - "app_installed_team_id": "T5J4Q04QG", - "bot_id": "B00" -}; -expectType(viewSubmissionPayload); \ No newline at end of file diff --git a/types/.gitkeep b/types/.gitkeep deleted file mode 100644 index e69de29bb..000000000