From a050256c7e08f3508b15f2d1a839758da0678a9c Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Wed, 8 Nov 2023 11:32:47 -0800 Subject: [PATCH 01/13] [WIP] Add function listener support --- src/App.ts | 11 ++ src/WorkflowFunction.ts | 217 ++++++++++++++++++++++++++++++++ src/errors.ts | 6 + src/types/events/base-events.ts | 35 ++++++ 4 files changed, 269 insertions(+) create mode 100644 src/WorkflowFunction.ts diff --git a/src/App.ts b/src/App.ts index 5fea2290c..906b6d66a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -61,6 +61,7 @@ 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 { WorkflowFunction, WorkflowFunctionMiddleware } from './WorkflowFunction'; // 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 @@ -517,6 +518,16 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> return this; } + /** + * Register WorkflowFunction middleware + */ + public function(callbackId: string, ...listeners: WorkflowFunctionMiddleware): this { + const fn = new WorkflowFunction(callbackId, listeners); + const m = fn.getMiddleware(); + this.middleware.push(m); + return this; + } + /** * Convenience method to call start on the receiver * diff --git a/src/WorkflowFunction.ts b/src/WorkflowFunction.ts new file mode 100644 index 000000000..23609f0d6 --- /dev/null +++ b/src/WorkflowFunction.ts @@ -0,0 +1,217 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + WorkflowsStepCompletedResponse, + WorkflowsStepFailedResponse, + // FunctionCompleteErrorResponse, + // FunctionCompleteSuccessResponse, +} from '@slack/web-api'; +import { + Middleware, + AllMiddlewareArgs, + AnyMiddlewareArgs, + SlackEventMiddlewareArgs, + FunctionExecutedEvent, +} from './types'; +import processMiddleware from './middleware/process'; +import { WorkflowFunctionInitializationError } from './errors'; + +/** Interfaces */ + +export interface FunctionCompleteArguments { + outputs?: { + [key: string]: any; + }; +} + +export interface FunctionFailArguments { + error: string; +} + +export interface FunctionCompleteFn { + // TODO :: import FunctionCompleteErrorResponse from @slack/web-api + (params?: FunctionCompleteArguments): Promise<WorkflowsStepCompletedResponse>; +} + +export interface FunctionFailFn { + // TODO :: import FunctionCompleteErrorResponse from @slack/web-api + (params: FunctionFailArguments): Promise<WorkflowsStepFailedResponse>; +} + +export interface WorkflowFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { + function: FunctionExecutedEvent; + complete: FunctionCompleteFn; + fail: FunctionFailFn; +} + +/** Types */ + +export type SlackWorkflowFunctionMiddlewareArgs = WorkflowFunctionExecuteMiddlewareArgs; + +export type WorkflowFunctionExecuteMiddleware = Middleware<WorkflowFunctionExecuteMiddlewareArgs>; + +export type WorkflowFunctionMiddleware = WorkflowFunctionExecuteMiddleware[]; + +export type AllWorkflowFunctionMiddlewareArgs + <T extends SlackWorkflowFunctionMiddlewareArgs = SlackWorkflowFunctionMiddlewareArgs> = T & AllMiddlewareArgs; + +/** Constants */ + +const VALID_PAYLOAD_TYPES = new Set(['function_executed']); + +/** Class */ + +export class WorkflowFunction { + /** Function callback_id */ + public callbackId: string; + + /** Function definition */ + private middleware: WorkflowFunctionMiddleware; + + public constructor( + callbackId: string, + middleware: WorkflowFunctionMiddleware, + ) { + validate(callbackId, middleware); + + this.callbackId = callbackId; + this.middleware = middleware; + } + + public getMiddleware(): Middleware<AnyMiddlewareArgs> { + return async (args): Promise<any> => { + if (isFunctionEvent(args) && this.matchesConstraints(args)) { + return this.processEvent(args); + } + return args.next(); + }; + } + + private matchesConstraints(args: SlackWorkflowFunctionMiddlewareArgs): boolean { + return args.payload.function.callback_id === this.callbackId; + } + + private async processEvent(args: AllWorkflowFunctionMiddlewareArgs): Promise<void> { + const functionArgs = prepareFunctionArgs(args); + const stepMiddleware = this.getStepMiddleware(); + return processStepMiddleware(functionArgs, stepMiddleware); + } + + private getStepMiddleware(): WorkflowFunctionMiddleware { + return this.middleware; + } +} + +/** Helper Functions */ + +export function validate(callbackId: string, listeners: WorkflowFunctionMiddleware): void { + // Ensure callbackId is valid + if (typeof callbackId !== 'string') { + const errorMsg = 'WorkflowFunction expects a callback_id as the first argument'; + throw new WorkflowFunctionInitializationError(errorMsg); + } + + // TODO: Validate that all array members are functions + console.log(listeners); +} + +/** + * `processStepMiddleware()` invokes each callback for lifecycle event + * @param args workflow_step_edit action + */ +export async function processStepMiddleware( + args: AllWorkflowFunctionMiddlewareArgs, + middleware: WorkflowFunctionMiddleware, +): Promise<void> { + const { context, client, logger } = args; + const callbacks = [...middleware] as Middleware<AnyMiddlewareArgs>[]; + const lastCallback = callbacks.pop(); + + if (lastCallback !== undefined) { + await processMiddleware( + callbacks, args, context, client, logger, + async () => lastCallback({ ...args, context, client, logger }), + ); + } +} + +export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllWorkflowFunctionMiddlewareArgs { + return VALID_PAYLOAD_TYPES.has(args.payload.type); +} + +// TODO :: might be important for upcoming CLI compatability. Remove if not. +// function selectToken(context: Context): string | undefined { +// return context.botToken !== undefined ? context.botToken : context.userToken; +// } + +/** + * Factory for `complete()` utility + * @param args function_executed event + */ +function createFunctionComplete( + args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>, +): FunctionCompleteFn { + const { + // context, // TODO : remove if not helpful for CLI + client, + payload: { function_execution_id }, + } = args; + // const token = selectToken(context); // TODO : remove if not helpful for CLI + + return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ + outputs: params.outputs, + function_execution_id, + }); + + // return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.apiCall('functions.completeSuccess', { + // outputs: params.outputs, + // function_execution_id, + // }); +} + +/** + * Factory for `fail()` utility + * @param args function_executed event + */ +function createFunctionFail( + args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>, +): FunctionFailFn { + const { + // context, // TODO : remove if not helpful for CLI + client, + payload: { function_execution_id }, + } = args; + // const token = selectToken(context); // TODO : remove if not helpful for CLI + + return (params: Parameters<FunctionFailFn>[0]) => { + const { error } = params ?? {}; + + return client.functions.completeError({ + error, + function_execution_id, + }); + + // return client.apiCall('functions.completeError', { + // function_execution_id, + // error, + // }); + }; +} + +/** + * `prepareFunctionArgs()` takes in a function's args and: + * 1. removes the next() passed in from App-level middleware processing + * - events will *not* continue down global middleware chain to subsequent listeners + * 2. augments args with step lifecycle-specific properties/utilities + * */ +export function prepareFunctionArgs(args: any): AllWorkflowFunctionMiddlewareArgs { + const { next: _next, ...functionArgs } = args; + const preparedArgs: any = { ...functionArgs }; + + // Utility args + preparedArgs.function = preparedArgs.event.function; // ie, function definition + preparedArgs.inputs = preparedArgs.event.inputs; + preparedArgs.complete = createFunctionComplete(preparedArgs); + preparedArgs.fail = createFunctionFail(preparedArgs); + + return preparedArgs; +} diff --git a/src/errors.ts b/src/errors.ts index b8ed369c7..3f7db700b 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,6 +34,8 @@ export enum ErrorCode { UnknownError = 'slack_bolt_unknown_error', WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', + + WorkflowFunctionInitializationError = 'slack_bolt_workflow_function_initialization_error', } export class UnknownError extends Error implements CodedError { @@ -138,3 +140,7 @@ export class MultipleListenerError extends Error implements CodedError { export class WorkflowStepInitializationError extends Error implements CodedError { public code = ErrorCode.WorkflowStepInitializationError; } + +export class WorkflowFunctionInitializationError extends Error implements CodedError { + public code = ErrorCode.WorkflowFunctionInitializationError; +} diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index 8c5bdb9be..cb9cd793d 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -35,6 +35,7 @@ export type SlackEvent = | FilePublicEvent | FileSharedEvent | FileUnsharedEvent + | FunctionExecutedEvent | GridMigrationFinishedEvent | GridMigrationStartedEvent | GroupArchiveEvent @@ -425,6 +426,40 @@ export interface FileUnsharedEvent { event_ts: string; } +export interface FunctionParams { + type?: string, + name?: string, + description?: string, + title?: string, + is_required?: boolean, +} + +export interface FunctionInputValues { + [key: string]: unknown; +} + +export type FunctionOutputValues = FunctionInputValues; + +// TODO :: Update this with new payload info +export interface FunctionExecutedEvent { + type: 'function_executed', + function: { + id: string, + callback_id: string, + title: string, + description: string, + type: string, + input_parameters: FunctionParams[], + output_parameters: FunctionParams[], + app_id: string, + date_updated: number, + }, + function_execution_id: string, + inputs: FunctionInputValues, + workflow_token: string, // xwfp-xxxxxxxxxxx + event_ts: string, +} + export interface GridMigrationFinishedEvent { type: 'grid_migration_finished'; enterprise_id: string; From 188813cc4990daeb22341025dacf496b596dfe8b Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Thu, 9 Nov 2023 17:20:14 -0800 Subject: [PATCH 02/13] [WIP] Add foundation of CLI hooks support --- bin/cli/check-update.js | 295 ++++++++++++++++++++++++++++++++++++++++ bin/cli/get-hooks.js | 17 +++ bin/cli/get-manifest.js | 80 +++++++++++ bin/cli/start.js | 43 ++++++ package.json | 6 + src/WorkflowFunction.ts | 4 +- 6 files changed, 443 insertions(+), 2 deletions(-) create mode 100755 bin/cli/check-update.js create mode 100755 bin/cli/get-hooks.js create mode 100755 bin/cli/get-manifest.js create mode 100755 bin/cli/start.js diff --git a/bin/cli/check-update.js b/bin/cli/check-update.js new file mode 100755 index 000000000..7f9d21246 --- /dev/null +++ b/bin/cli/check-update.js @@ -0,0 +1,295 @@ +#!/usr/bin/env node --no-warnings +const fs = require('fs'); +const path = require('path'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +const SLACK_BOLT_SDK = '@slack/bolt'; +let checkUpdateExports = {}; + +/** + * Implements the check-update hook and looks for available SDK updates. + * Returns an object detailing info on Slack dependencies to pass up to the CLI. +*/ +(async function _(cwd) { + let updates = await checkForSDKUpdates(cwd); + console.log(JSON.stringify(updates)); // stdout +}(process.cwd())); + +/** + * Wraps and parses the output of the exec() function + * @param command the command being run by the exec() function + */ +async function execWrapper (command) { + const { stdout } = await exec(command); + return stdout.trim(); +} + +/** + * Checks for available SDK updates for specified dependencies, creates a version map, + * and then wraps everything up into a response to be passed to the CLI. + * @param cwd the current working directory + */ +async function checkForSDKUpdates(cwd) { + const { versionMap, inaccessibleFiles } = await createVersionMap(cwd); + const updateResp = createUpdateResp(versionMap, inaccessibleFiles); + return updateResp; +} + +/** + * Create a version map that contains each (Slack) dependency, detailing info about + * current and latest versions, as well as if breaking changes are present or if there + * were any errors with getting version retrieval. + * @param cwd the current working directory of the CLI project + */ +async function createVersionMap(cwd) { + const { versionMap, inaccessibleFiles } = await readProjectDependencies(cwd); + + if (versionMap && versionMap[SLACK_BOLT_SDK]) { + const current = versionMap[SLACK_BOLT_SDK].current || ''; + let latest = '', + error = null; + + try { + latest = await fetchLatestModuleVersion(SLACK_BOLT_SDK); + } catch (err) { + error = err; + } + const update = !!current && !!latest && current !== latest; + const breaking = hasBreakingChange(current, latest); + + versionMap[SLACK_BOLT_SDK] = { + ...versionMap[SLACK_BOLT_SDK], + latest, + update, + breaking, + error, + }; + } + + return { versionMap, inaccessibleFiles }; +} + +/** + * Reads project dependencies - cycles through project dependency files, extracts + * the listed dependencies, and adds it in to the version map with version info. + * @param cwd the current working directory of the CLI project + */ +async function readProjectDependencies(cwd) { + const versionMap = {}; + const { dependencyFile, inaccessibleFiles } = await gatherDependencyFiles( + cwd + ); + + try { + const [sdk, value] = + await checkUpdateExports.extractDependencies(dependencyFile.body, dependencyFile.name); + + if (sdk !== '' && sdk === SLACK_BOLT_SDK) { + versionMap[SLACK_BOLT_SDK] = { + name: sdk, + current: value.version, + }; + } + } catch (err) { + inaccessibleFiles.push({ name: dependencyFile.name, error: err }); + } + + return { versionMap, inaccessibleFiles }; +} + +/** + * Reads and parses JSON file - if it works, returns the file contents. + * If not, returns an empty object + * @param filePath the path of the file being read + */ +async function getJSON(filePath) { + let fileContents = {}; + try { + if (fs.existsSync(filePath)) { + fileContents = JSON.parse(fs.readFileSync(filePath, 'utf8')); + } else { + throw new Error('Cannot find a file at path ' + filePath); + } + } catch (err) { + throw new Error(err); + } + return fileContents; +} + +/** + * Gathers all related dependency files for the CLI project (package.json). + * @param cwd the current working directory of the CLI project + */ +async function gatherDependencyFiles(cwd) { + const { jsonDepFile, inaccessibleFiles } = await getJSONFiles(cwd); + const dependencyFile = jsonDepFile; + return { dependencyFile, inaccessibleFiles }; +} + +/** + * Gets the needed files that contain dependency info (package.json). + * @param cwd the current working directory of the CLI project + */ +async function getJSONFiles(cwd) { + const packageJSON = 'package.json'; + const jsonDepFile = {}; + const inaccessibleFiles = []; + + try { + const jsonFile = await getJSON(`${cwd}/${packageJSON}`); + const jsonIsParsable = + jsonFile && + typeof jsonFile === 'object' && + !Array.isArray(jsonFile) && + jsonFile.dependencies; + + if (jsonIsParsable) { + jsonDepFile.name = packageJSON; + jsonDepFile.body= jsonFile; + } + } catch (err) { + inaccessibleFiles.push({ name: packageJSON, error: err }); + } + + return { jsonDepFile, inaccessibleFiles }; +} + +/** + * Pulls dependencies from a given file and JSON structure. + * @param json JSON information that includes dependencies + * @param fileName name of the file that the dependency list is coming from + */ +checkUpdateExports.extractDependencies = async (json, fileName) => { + // Determine if the JSON passed is an object + const jsonIsParsable = + json !== null && typeof json === 'object' && !Array.isArray(json); + + if (jsonIsParsable) { + let boltCurrentVersion = ''; + if (json['dependencies']['@slack/bolt']) { + const boltCurrentVersionOutput = JSON.parse( + await checkUpdateExports.getBoltCurrentVersion() + ); + + if (boltCurrentVersionOutput !== '') { + boltCurrentVersion = + boltCurrentVersionOutput['dependencies']['@slack/bolt']['version']; + } + } + + return [ + '@slack/bolt', + { + version: boltCurrentVersion, + }, + ]; + } + + return []; +}; + +/** + * Gets the latest module version. + * @param moduleName the module that the latest version is being queried for + */ +async function fetchLatestModuleVersion(moduleName) { + let command = ''; + if (moduleName === '@slack/bolt') { + command = `npm info ${moduleName} version --tag next-gen`; + } else if (moduleName === '@slack/deno-slack-sdk') { + command = `npm info ${moduleName} version --tag latest`; + } + const stdout = await execWrapper(command); + + return stdout; +} + +/** + * Checks if a dependency's upgrade from a current to the latest version will cause a + * breaking change. + * @param current current dependency version in project + * @param latest most up-to-date dependency version available on NPM + */ +function hasBreakingChange(current, latest) { + return current !== latest; +} + +/** + * Creates the update response - returns an object in the expected response format. + * @param versionMap version map of checked dependencies, current versions, and info on upgrade + breaking changes + * @param inaccessibleFiles array of files that could not be read or accessed + */ +function createUpdateResp(versionMap, inaccessibleFiles) { + const name = 'the Slack SDK'; + const releases = []; + const url = 'https://api.slack.com/future/changelog'; + const fileErrorMsg = createFileErrorMsg(inaccessibleFiles); + + let error = null; + let errorMsg = ''; + let message = ''; + + + // Output information for each dependency + for (const sdk of Object.values(versionMap)) { + // Dependency has an update OR the fetch of update failed + if (sdk) { + releases.push(sdk); + + // Add the dependency that failed to be fetched to the top-level error message + if (sdk.error && sdk.error.message) { + errorMsg += errorMsg + ? `, ${sdk}` + : `An error occurred fetching updates for the following packages: ${sdk.name}`; + } + } + } + + // Surface release notes for breaking changes + if (releases && releases[0] && releases[0].breaking) { + message = `Learn more about the breaking change at https://github.com/slackapi/bolt-js/releases/tag/@slack/bolt@${releases[0].latest}` + } + + // If there were issues accessing dependency files, append error message(s) + if (inaccessibleFiles.length) { + errorMsg += errorMsg ? `\n\n ${fileErrorMsg}` : fileErrorMsg; + } + + if (errorMsg) { + error = { message: errorMsg }; + } + + return { + name, + message, + releases, + url, + error, + }; +} + +/** + * Returns error when dependency files cannot be read. + * @param inaccessibleFiles array of files that could not be read or accessed + */ +function createFileErrorMsg(inaccessibleFiles) { + let fileErrorMsg = ''; + + // There were issues with reading some of the files that were found + for (const file of inaccessibleFiles) { + fileErrorMsg += fileErrorMsg + ? `\n ${file.name}: ${file.error.message}` + : `An error occurred while reading the following files: \n\n ${file.name}: ${file.error.message}`; + } + + return fileErrorMsg; +} + +/** + * Queries for current Bolt version of the project. + */ +checkUpdateExports.getBoltCurrentVersion = async () => { + const stdout = await execWrapper('npm list @slack/bolt --depth=0 --json'); + return stdout; +}; \ No newline at end of file diff --git a/bin/cli/get-hooks.js b/bin/cli/get-hooks.js new file mode 100755 index 000000000..d8668e18d --- /dev/null +++ b/bin/cli/get-hooks.js @@ -0,0 +1,17 @@ +#!/usr/bin/env node +console.log(JSON.stringify({ + hooks: { + 'get-manifest': 'npx -q --no-install -p @slack/bolt slack-cli-get-manifest', + 'check-update': 'npx -q --no-install -p @slack/bolt slack-cli-check-update', + start: 'npx -q --no-install -p @slack/bolt slack-cli-start', + }, + config: { + watch: { + 'filter-regex': '^manifest\\.(ts|js|json)$', + paths: [ + '.', + ], + }, + 'sdk-managed-connection-enabled': true, + }, +})); \ No newline at end of file diff --git a/bin/cli/get-manifest.js b/bin/cli/get-manifest.js new file mode 100755 index 000000000..303ba8afc --- /dev/null +++ b/bin/cli/get-manifest.js @@ -0,0 +1,80 @@ +#!/usr/bin/env node +const fs = require('fs'); +const path = require('path'); + +/** + * Implements the get-manifest script hook required by the Slack CLI + * Returns a manifest JSON string to stdout. +*/ +(function _(cwd) { + let manifest = getManifestData(cwd); + console.log(JSON.stringify(manifest)); +}(process.cwd())); + +/** + * Returns manifest data + * @param searchDir path to begin searching at + */ +function getManifestData(searchDir) { + const manifestJSON = readManifestJSONFile(searchDir, 'manifest.json'); + + if (!manifestJSON) { + const msg = 'Unable to find a manifest file in this project'; + throw new Error(msg); + } + + return manifestJSON; +} + +/** + * Returns a manifest.json if it exists, null otherwise + * @param searchDir typically current working directory + * @param filename file to search for + */ +function readManifestJSONFile(searchDir, filename) { + let jsonFilePath, manifestJSON; + + try { + jsonFilePath = find(searchDir, filename); + if (fs.existsSync(jsonFilePath)) { + manifestJSON = JSON.parse(fs.readFileSync(jsonFilePath, 'utf8')); + } + } catch (error) { + console.error(error) + return null; + } + + return manifestJSON; +} + +/** + * Search for provided file path. + * Returns full path when filename is found or null if no file found. + * @param currentPath string of current path + * @param targetFilename filename to match + * @returns full file path string relative to starting path or null + * */ +function find(currentPath, targetFilename) { + // TODO Cache searched paths and check that they haven't been explored already + // This guards against rare edge case of a subdir in the file tree which is + // symlinked back to root or in such a way that creates a cycle. Can also implement + // max depth check. + if (currentPath.endsWith(`/${targetFilename}`)) { + return currentPath; + } + + if (fs.existsSync(currentPath) && fs.lstatSync(currentPath).isDirectory()) { + let foundEntry; + const dirents = fs.readdirSync(currentPath); + for (const entry of dirents) { + if (entry !== 'node_modules') { + let newPath = path.resolve(currentPath, entry); + foundEntry = find(newPath, targetFilename); + if (foundEntry) { + return foundEntry; + } + } + } + return null; + } +} \ No newline at end of file diff --git a/bin/cli/start.js b/bin/cli/start.js new file mode 100755 index 000000000..c3d1bfbe5 --- /dev/null +++ b/bin/cli/start.js @@ -0,0 +1,43 @@ +#!/usr/bin/env node +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { main: pkgJSONMain } = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); + +// Run script hook verifies that requirements for running an App in +// in developerMode (via Socket Mode) are met +(function _(cwd, customPath) { + // TODO - Format so that its less miss-able in output + console.log('Preparing local run in developer mode (Socket Mode)'); + // Check required local run tokens + validate(); + + // tries the provided path, then package.json main, then defaults to index.js in the current + // working directory + const pkgJSONDefault = 'index.js'; + const fullPath = path.resolve(cwd, customPath ? customPath : pkgJSONMain ? pkgJSONMain : pkgJSONDefault); + console.log(fullPath); + + // Kick off a subprocess to run the app in development mode + const app = spawn('node', [`${fullPath}`]); + app.stdout.setEncoding('utf-8'); + app.stdout.on('data', (data) => { + process.stdout.write(data); + }); + app.stderr.on('data', (data) => { + process.stderr.write(data); + }); + + app.on('close', (code) => { + console.log(`bolt-app local run exited with code ${code}`); + }); +}(process.cwd(), process.env.SLACK_CLI_CUSTOM_FILE_PATH)); + +function validate() { + if (!process.env.SLACK_CLI_XOXB) { + throw new Error('Missing local run bot token. Please see slack-cli maintainers to troubleshoot.'); + } + if (!process.env.SLACK_CLI_XAPP) { + throw new Error('Missing local run app token. Please see slack-cli maintainers to troubleshoot'); + } +} \ No newline at end of file diff --git a/package.json b/package.json index 9312fd59c..55d13921c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,12 @@ "test:types": "tsd", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, + "bin": { + "slack-cli-get-hooks": "./bin/cli/get-hooks.js", + "slack-cli-get-manifest": "./bin/cli/get-manifest.js", + "slack-cli-check-update": "./bin/cli/check-update.js", + "slack-cli-start": "./bin/cli/start.js" + }, "repository": "slackapi/bolt", "homepage": "https://slack.dev/bolt-js", "bugs": { diff --git a/src/WorkflowFunction.ts b/src/WorkflowFunction.ts index 23609f0d6..ac3dbb28a 100644 --- a/src/WorkflowFunction.ts +++ b/src/WorkflowFunction.ts @@ -38,7 +38,7 @@ export interface FunctionFailFn { } export interface WorkflowFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { - function: FunctionExecutedEvent; + definition: FunctionExecutedEvent; complete: FunctionCompleteFn; fail: FunctionFailFn; } @@ -208,7 +208,7 @@ export function prepareFunctionArgs(args: any): AllWorkflowFunctionMiddlewareArg const preparedArgs: any = { ...functionArgs }; // Utility args - preparedArgs.function = preparedArgs.event.function; // ie, function definition + preparedArgs.definition = preparedArgs.event.function; // ie, function definition preparedArgs.inputs = preparedArgs.event.inputs; preparedArgs.complete = createFunctionComplete(preparedArgs); preparedArgs.fail = createFunctionFail(preparedArgs); From f31d8df3d8b815412a206cdc84991649d6d837d0 Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Fri, 8 Dec 2023 14:24:13 -0800 Subject: [PATCH 03/13] [WIP] Add support for interactivity --- src/App.ts | 47 ++++++++++++++++++++++++++- src/WorkflowFunction.ts | 71 +++++++++++++++++++++-------------------- 2 files changed, 82 insertions(+), 36 deletions(-) diff --git a/src/App.ts b/src/App.ts index 906b6d66a..dc19f7353 100644 --- a/src/App.ts +++ b/src/App.ts @@ -61,7 +61,7 @@ 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 { WorkflowFunction, WorkflowFunctionMiddleware } from './WorkflowFunction'; +import { FunctionCompleteFn, FunctionFailFn, WorkflowFunction, WorkflowFunctionMiddleware } from './WorkflowFunction'; // 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 @@ -949,12 +949,17 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> } } + const { functionExecutionId, functionBotToken } = extractFunctionContext(body); + const context: Context = { ...authorizeResult, ...event.customProperties, isEnterpriseInstall, retryNum: event.retryNum, retryReason: event.retryReason, + // Only add function-related props to context if truthy + ...(functionExecutionId && { functionExecutionId }), + ...(functionBotToken && { functionBotToken }), }; // Factory for say() utility @@ -1012,6 +1017,8 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> /** Ack function might be set below */ // eslint-disable-next-line @typescript-eslint/no-explicit-any ack?: AckFn<any>; + complete?: FunctionCompleteFn; + fail?: FunctionFailFn; } = { body: bodyArg, payload, @@ -1067,6 +1074,25 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> let { client } = this; const token = selectToken(context); + // TODO :: WorkflowFunction owns these same utilities. Rework TS to allow for reuse. + // Set complete() and fail() utilities for function-related interactivity + if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) { + listenerArgs.complete = (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ + token: context.functionBotToken, + outputs: params.outputs || {}, + function_execution_id: context.functionExecutionId, + }); + listenerArgs.fail = (params: Parameters<FunctionFailFn>[0]) => { + const { error } = params ?? {}; + + return client.functions.completeError({ + token: context.functionBotToken, + error, + function_execution_id: context.functionExecutionId, + }); + }; + } + if (token !== undefined) { let pool; const clientOptionsCopy = { ...this.clientOptions }; @@ -1573,6 +1599,25 @@ function escapeHtml(input: string | undefined | null): string { return ''; } +function extractFunctionContext(body: StringIndexed) { + let functionExecutionId; + let functionBotToken; + + // function_executed event + if (body.event && body.event.function_execution_id) { + functionExecutionId = body.event.function_execution_id; + functionBotToken = body.event.bot_access_token; + } + + // interactivity (block_actions) + if (body.function_data) { + functionExecutionId = body.function_data.execution_id; + functionBotToken = body.bot_access_token; + } + + return { functionExecutionId, functionBotToken }; +} + // ---------------------------- // Instrumentation // Don't change the position of the following code diff --git a/src/WorkflowFunction.ts b/src/WorkflowFunction.ts index ac3dbb28a..ae5ed65c8 100644 --- a/src/WorkflowFunction.ts +++ b/src/WorkflowFunction.ts @@ -1,16 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { - WorkflowsStepCompletedResponse, - WorkflowsStepFailedResponse, - // FunctionCompleteErrorResponse, - // FunctionCompleteSuccessResponse, + WebClient, + FunctionsCompleteErrorResponse, + FunctionsCompleteSuccessResponse, } from '@slack/web-api'; import { Middleware, AllMiddlewareArgs, AnyMiddlewareArgs, SlackEventMiddlewareArgs, - FunctionExecutedEvent, + Context, } from './types'; import processMiddleware from './middleware/process'; import { WorkflowFunctionInitializationError } from './errors'; @@ -28,17 +27,14 @@ export interface FunctionFailArguments { } export interface FunctionCompleteFn { - // TODO :: import FunctionCompleteErrorResponse from @slack/web-api - (params?: FunctionCompleteArguments): Promise<WorkflowsStepCompletedResponse>; + (params?: FunctionCompleteArguments): Promise<FunctionsCompleteSuccessResponse>; } export interface FunctionFailFn { - // TODO :: import FunctionCompleteErrorResponse from @slack/web-api - (params: FunctionFailArguments): Promise<WorkflowsStepFailedResponse>; + (params: FunctionFailArguments): Promise<FunctionsCompleteErrorResponse>; } export interface WorkflowFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { - definition: FunctionExecutedEvent; complete: FunctionCompleteFn; fail: FunctionFailFn; } @@ -64,7 +60,6 @@ export class WorkflowFunction { /** Function callback_id */ public callbackId: string; - /** Function definition */ private middleware: WorkflowFunctionMiddleware; public constructor( @@ -110,8 +105,13 @@ export function validate(callbackId: string, listeners: WorkflowFunctionMiddlewa throw new WorkflowFunctionInitializationError(errorMsg); } - // TODO: Validate that all array members are functions - console.log(listeners); + // Ensure all listeners are functions + listeners.forEach((listener) => { + if (!(listener instanceof Function)) { + const errorMsg = 'All WorkflowFunction listeners must be functions'; + throw new WorkflowFunctionInitializationError(errorMsg); + } + }); } /** @@ -138,62 +138,59 @@ export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllWorkflowFun return VALID_PAYLOAD_TYPES.has(args.payload.type); } -// TODO :: might be important for upcoming CLI compatability. Remove if not. -// function selectToken(context: Context): string | undefined { -// return context.botToken !== undefined ? context.botToken : context.userToken; -// } +function selectToken(context: Context): string | undefined { + return context.functionBotToken ? context.functionBotToken : context.botToken || context.userToken; +} /** * Factory for `complete()` utility * @param args function_executed event */ -function createFunctionComplete( +export function createFunctionComplete( args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>, ): FunctionCompleteFn { const { - // context, // TODO : remove if not helpful for CLI + context, client, payload: { function_execution_id }, } = args; - // const token = selectToken(context); // TODO : remove if not helpful for CLI + const token = selectToken(context); return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ - outputs: params.outputs, + // TODO :: Possible to change to context.functionBotToken, but need + // to establish if there will ever be a case where botToken would be used instead + // as a fallback + token, + outputs: params.outputs || {}, function_execution_id, }); - - // return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.apiCall('functions.completeSuccess', { - // outputs: params.outputs, - // function_execution_id, - // }); } /** * Factory for `fail()` utility * @param args function_executed event */ -function createFunctionFail( +export function createFunctionFail( args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>, ): FunctionFailFn { const { - // context, // TODO : remove if not helpful for CLI + context, client, payload: { function_execution_id }, } = args; - // const token = selectToken(context); // TODO : remove if not helpful for CLI + const token = selectToken(context); return (params: Parameters<FunctionFailFn>[0]) => { const { error } = params ?? {}; return client.functions.completeError({ + // TODO :: Possible to change to context.functionBotToken, but need + // to establish if there will ever be a case where botToken would be used instead + // as a fallback + token, error, function_execution_id, }); - - // return client.apiCall('functions.completeError', { - // function_execution_id, - // error, - // }); }; } @@ -207,8 +204,12 @@ export function prepareFunctionArgs(args: any): AllWorkflowFunctionMiddlewareArg const { next: _next, ...functionArgs } = args; const preparedArgs: any = { ...functionArgs }; + // The use of a functionBotToken establishes continuity between + // a function_executed event and subsequent interactive events + const client = new WebClient(preparedArgs.context.functionBotToken, { ...functionArgs.client }); + preparedArgs.client = client; + // Utility args - preparedArgs.definition = preparedArgs.event.function; // ie, function definition preparedArgs.inputs = preparedArgs.event.inputs; preparedArgs.complete = createFunctionComplete(preparedArgs); preparedArgs.fail = createFunctionFail(preparedArgs); From 3faab87c7009eae3edd8db773ebbc0cf5c6353da Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Thu, 14 Dec 2023 16:24:55 -0800 Subject: [PATCH 04/13] [WIP] Add attachFunctionToken opt-out option --- src/App.ts | 20 ++++++++++++++------ src/WorkflowFunction.ts | 14 +++++--------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/App.ts b/src/App.ts index dc19f7353..6a98ef6ac 100644 --- a/src/App.ts +++ b/src/App.ts @@ -109,6 +109,7 @@ export interface AppOptions { tokenVerificationEnabled?: boolean; deferInitialization?: boolean; extendedErrorHandler?: boolean; + attachFunctionToken?: boolean; } export { LogLevel, Logger } from '@slack/logger'; @@ -269,6 +270,8 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> private initialized: boolean; + private attachFunctionToken: boolean; + public constructor({ signingSecret = undefined, endpoints = undefined, @@ -301,6 +304,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> tokenVerificationEnabled = true, extendedErrorHandler = false, deferInitialization = false, + attachFunctionToken = true, }: AppOptions = {}) { /* ------------------------ Developer mode ----------------------------- */ this.developerMode = developerMode; @@ -333,6 +337,9 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> this.errorHandler = defaultErrorHandler(this.logger) as AnyErrorHandler; this.extendedErrorHandler = extendedErrorHandler; + // Override token with functionBotToken in function-related handlers + this.attachFunctionToken = attachFunctionToken; + /* ------------------------ Set client options ------------------------*/ this.clientOptions = clientOptions !== undefined ? clientOptions : {}; if (agent !== undefined && this.clientOptions.agent === undefined) { @@ -949,19 +956,20 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> } } - const { functionExecutionId, functionBotToken } = extractFunctionContext(body); - const context: Context = { ...authorizeResult, ...event.customProperties, isEnterpriseInstall, retryNum: event.retryNum, retryReason: event.retryReason, - // Only add function-related props to context if truthy - ...(functionExecutionId && { functionExecutionId }), - ...(functionBotToken && { functionBotToken }), }; + if (this.attachFunctionToken) { + const { functionExecutionId, functionBotToken } = extractFunctionContext(body); + if (functionExecutionId) { context.functionExecutionId = functionExecutionId; } + if (functionBotToken) { context.functionBotToken = functionBotToken; } + } + // Factory for say() utility const createSay = (channelId: string): SayFn => { const token = selectToken(context); @@ -1604,7 +1612,7 @@ function extractFunctionContext(body: StringIndexed) { let functionBotToken; // function_executed event - if (body.event && body.event.function_execution_id) { + if (body.event && body.event.type === 'function_executed' && body.event.function_execution_id) { functionExecutionId = body.event.function_execution_id; functionBotToken = body.event.bot_access_token; } diff --git a/src/WorkflowFunction.ts b/src/WorkflowFunction.ts index ae5ed65c8..848cd7a48 100644 --- a/src/WorkflowFunction.ts +++ b/src/WorkflowFunction.ts @@ -139,6 +139,7 @@ export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllWorkflowFun } function selectToken(context: Context): string | undefined { + // If attachFunctionToken = false, fallback to botToken or userToken return context.functionBotToken ? context.functionBotToken : context.botToken || context.userToken; } @@ -157,9 +158,6 @@ export function createFunctionComplete( const token = selectToken(context); return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ - // TODO :: Possible to change to context.functionBotToken, but need - // to establish if there will ever be a case where botToken would be used instead - // as a fallback token, outputs: params.outputs || {}, function_execution_id, @@ -184,9 +182,6 @@ export function createFunctionFail( const { error } = params ?? {}; return client.functions.completeError({ - // TODO :: Possible to change to context.functionBotToken, but need - // to establish if there will ever be a case where botToken would be used instead - // as a fallback token, error, function_execution_id, @@ -203,10 +198,11 @@ export function createFunctionFail( export function prepareFunctionArgs(args: any): AllWorkflowFunctionMiddlewareArgs { const { next: _next, ...functionArgs } = args; const preparedArgs: any = { ...functionArgs }; + const token = selectToken(functionArgs.context); - // The use of a functionBotToken establishes continuity between - // a function_executed event and subsequent interactive events - const client = new WebClient(preparedArgs.context.functionBotToken, { ...functionArgs.client }); + // Making calls with a functionBotToken establishes continuity between + // a function_executed event and subsequent interactive events (actions) + const client = new WebClient(token, { ...functionArgs.client }); preparedArgs.client = client; // Utility args From b46f47029aa4f9e64eb057f0e3b73a9d57d581b4 Mon Sep 17 00:00:00 2001 From: Ethan Zimbelman <ethan.zimbelman@me.com> Date: Fri, 5 Jan 2024 11:07:52 -0800 Subject: [PATCH 05/13] [WIP] Revert CLI hook support within Bolt --- bin/cli/check-update.js | 295 ---------------------------------------- bin/cli/get-hooks.js | 17 --- bin/cli/get-manifest.js | 80 ----------- bin/cli/start.js | 43 ------ package.json | 6 - 5 files changed, 441 deletions(-) delete mode 100755 bin/cli/check-update.js delete mode 100755 bin/cli/get-hooks.js delete mode 100755 bin/cli/get-manifest.js delete mode 100755 bin/cli/start.js diff --git a/bin/cli/check-update.js b/bin/cli/check-update.js deleted file mode 100755 index 7f9d21246..000000000 --- a/bin/cli/check-update.js +++ /dev/null @@ -1,295 +0,0 @@ -#!/usr/bin/env node --no-warnings -const fs = require('fs'); -const path = require('path'); -const util = require('util'); -const exec = util.promisify(require('child_process').exec); - -const SLACK_BOLT_SDK = '@slack/bolt'; -let checkUpdateExports = {}; - -/** - * Implements the check-update hook and looks for available SDK updates. - * Returns an object detailing info on Slack dependencies to pass up to the CLI. -*/ -(async function _(cwd) { - let updates = await checkForSDKUpdates(cwd); - console.log(JSON.stringify(updates)); // stdout -}(process.cwd())); - -/** - * Wraps and parses the output of the exec() function - * @param command the command being run by the exec() function - */ -async function execWrapper (command) { - const { stdout } = await exec(command); - return stdout.trim(); -} - -/** - * Checks for available SDK updates for specified dependencies, creates a version map, - * and then wraps everything up into a response to be passed to the CLI. - * @param cwd the current working directory - */ -async function checkForSDKUpdates(cwd) { - const { versionMap, inaccessibleFiles } = await createVersionMap(cwd); - const updateResp = createUpdateResp(versionMap, inaccessibleFiles); - return updateResp; -} - -/** - * Create a version map that contains each (Slack) dependency, detailing info about - * current and latest versions, as well as if breaking changes are present or if there - * were any errors with getting version retrieval. - * @param cwd the current working directory of the CLI project - */ -async function createVersionMap(cwd) { - const { versionMap, inaccessibleFiles } = await readProjectDependencies(cwd); - - if (versionMap && versionMap[SLACK_BOLT_SDK]) { - const current = versionMap[SLACK_BOLT_SDK].current || ''; - let latest = '', - error = null; - - try { - latest = await fetchLatestModuleVersion(SLACK_BOLT_SDK); - } catch (err) { - error = err; - } - const update = !!current && !!latest && current !== latest; - const breaking = hasBreakingChange(current, latest); - - versionMap[SLACK_BOLT_SDK] = { - ...versionMap[SLACK_BOLT_SDK], - latest, - update, - breaking, - error, - }; - } - - return { versionMap, inaccessibleFiles }; -} - -/** - * Reads project dependencies - cycles through project dependency files, extracts - * the listed dependencies, and adds it in to the version map with version info. - * @param cwd the current working directory of the CLI project - */ -async function readProjectDependencies(cwd) { - const versionMap = {}; - const { dependencyFile, inaccessibleFiles } = await gatherDependencyFiles( - cwd - ); - - try { - const [sdk, value] = - await checkUpdateExports.extractDependencies(dependencyFile.body, dependencyFile.name); - - if (sdk !== '' && sdk === SLACK_BOLT_SDK) { - versionMap[SLACK_BOLT_SDK] = { - name: sdk, - current: value.version, - }; - } - } catch (err) { - inaccessibleFiles.push({ name: dependencyFile.name, error: err }); - } - - return { versionMap, inaccessibleFiles }; -} - -/** - * Reads and parses JSON file - if it works, returns the file contents. - * If not, returns an empty object - * @param filePath the path of the file being read - */ -async function getJSON(filePath) { - let fileContents = {}; - try { - if (fs.existsSync(filePath)) { - fileContents = JSON.parse(fs.readFileSync(filePath, 'utf8')); - } else { - throw new Error('Cannot find a file at path ' + filePath); - } - } catch (err) { - throw new Error(err); - } - return fileContents; -} - -/** - * Gathers all related dependency files for the CLI project (package.json). - * @param cwd the current working directory of the CLI project - */ -async function gatherDependencyFiles(cwd) { - const { jsonDepFile, inaccessibleFiles } = await getJSONFiles(cwd); - const dependencyFile = jsonDepFile; - return { dependencyFile, inaccessibleFiles }; -} - -/** - * Gets the needed files that contain dependency info (package.json). - * @param cwd the current working directory of the CLI project - */ -async function getJSONFiles(cwd) { - const packageJSON = 'package.json'; - const jsonDepFile = {}; - const inaccessibleFiles = []; - - try { - const jsonFile = await getJSON(`${cwd}/${packageJSON}`); - const jsonIsParsable = - jsonFile && - typeof jsonFile === 'object' && - !Array.isArray(jsonFile) && - jsonFile.dependencies; - - if (jsonIsParsable) { - jsonDepFile.name = packageJSON; - jsonDepFile.body= jsonFile; - } - } catch (err) { - inaccessibleFiles.push({ name: packageJSON, error: err }); - } - - return { jsonDepFile, inaccessibleFiles }; -} - -/** - * Pulls dependencies from a given file and JSON structure. - * @param json JSON information that includes dependencies - * @param fileName name of the file that the dependency list is coming from - */ -checkUpdateExports.extractDependencies = async (json, fileName) => { - // Determine if the JSON passed is an object - const jsonIsParsable = - json !== null && typeof json === 'object' && !Array.isArray(json); - - if (jsonIsParsable) { - let boltCurrentVersion = ''; - if (json['dependencies']['@slack/bolt']) { - const boltCurrentVersionOutput = JSON.parse( - await checkUpdateExports.getBoltCurrentVersion() - ); - - if (boltCurrentVersionOutput !== '') { - boltCurrentVersion = - boltCurrentVersionOutput['dependencies']['@slack/bolt']['version']; - } - } - - return [ - '@slack/bolt', - { - version: boltCurrentVersion, - }, - ]; - } - - return []; -}; - -/** - * Gets the latest module version. - * @param moduleName the module that the latest version is being queried for - */ -async function fetchLatestModuleVersion(moduleName) { - let command = ''; - if (moduleName === '@slack/bolt') { - command = `npm info ${moduleName} version --tag next-gen`; - } else if (moduleName === '@slack/deno-slack-sdk') { - command = `npm info ${moduleName} version --tag latest`; - } - const stdout = await execWrapper(command); - - return stdout; -} - -/** - * Checks if a dependency's upgrade from a current to the latest version will cause a - * breaking change. - * @param current current dependency version in project - * @param latest most up-to-date dependency version available on NPM - */ -function hasBreakingChange(current, latest) { - return current !== latest; -} - -/** - * Creates the update response - returns an object in the expected response format. - * @param versionMap version map of checked dependencies, current versions, and info on upgrade + breaking changes - * @param inaccessibleFiles array of files that could not be read or accessed - */ -function createUpdateResp(versionMap, inaccessibleFiles) { - const name = 'the Slack SDK'; - const releases = []; - const url = 'https://api.slack.com/future/changelog'; - const fileErrorMsg = createFileErrorMsg(inaccessibleFiles); - - let error = null; - let errorMsg = ''; - let message = ''; - - - // Output information for each dependency - for (const sdk of Object.values(versionMap)) { - // Dependency has an update OR the fetch of update failed - if (sdk) { - releases.push(sdk); - - // Add the dependency that failed to be fetched to the top-level error message - if (sdk.error && sdk.error.message) { - errorMsg += errorMsg - ? `, ${sdk}` - : `An error occurred fetching updates for the following packages: ${sdk.name}`; - } - } - } - - // Surface release notes for breaking changes - if (releases && releases[0] && releases[0].breaking) { - message = `Learn more about the breaking change at https://github.com/slackapi/bolt-js/releases/tag/@slack/bolt@${releases[0].latest}` - } - - // If there were issues accessing dependency files, append error message(s) - if (inaccessibleFiles.length) { - errorMsg += errorMsg ? `\n\n ${fileErrorMsg}` : fileErrorMsg; - } - - if (errorMsg) { - error = { message: errorMsg }; - } - - return { - name, - message, - releases, - url, - error, - }; -} - -/** - * Returns error when dependency files cannot be read. - * @param inaccessibleFiles array of files that could not be read or accessed - */ -function createFileErrorMsg(inaccessibleFiles) { - let fileErrorMsg = ''; - - // There were issues with reading some of the files that were found - for (const file of inaccessibleFiles) { - fileErrorMsg += fileErrorMsg - ? `\n ${file.name}: ${file.error.message}` - : `An error occurred while reading the following files: \n\n ${file.name}: ${file.error.message}`; - } - - return fileErrorMsg; -} - -/** - * Queries for current Bolt version of the project. - */ -checkUpdateExports.getBoltCurrentVersion = async () => { - const stdout = await execWrapper('npm list @slack/bolt --depth=0 --json'); - return stdout; -}; \ No newline at end of file diff --git a/bin/cli/get-hooks.js b/bin/cli/get-hooks.js deleted file mode 100755 index d8668e18d..000000000 --- a/bin/cli/get-hooks.js +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env node -console.log(JSON.stringify({ - hooks: { - 'get-manifest': 'npx -q --no-install -p @slack/bolt slack-cli-get-manifest', - 'check-update': 'npx -q --no-install -p @slack/bolt slack-cli-check-update', - start: 'npx -q --no-install -p @slack/bolt slack-cli-start', - }, - config: { - watch: { - 'filter-regex': '^manifest\\.(ts|js|json)$', - paths: [ - '.', - ], - }, - 'sdk-managed-connection-enabled': true, - }, -})); \ No newline at end of file diff --git a/bin/cli/get-manifest.js b/bin/cli/get-manifest.js deleted file mode 100755 index 303ba8afc..000000000 --- a/bin/cli/get-manifest.js +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env node -const fs = require('fs'); -const path = require('path'); - -/** - * Implements the get-manifest script hook required by the Slack CLI - * Returns a manifest JSON string to stdout. -*/ -(function _(cwd) { - let manifest = getManifestData(cwd); - console.log(JSON.stringify(manifest)); -}(process.cwd())); - -/** - * Returns manifest data - * @param searchDir path to begin searching at - */ -function getManifestData(searchDir) { - const manifestJSON = readManifestJSONFile(searchDir, 'manifest.json'); - - if (!manifestJSON) { - const msg = 'Unable to find a manifest file in this project'; - throw new Error(msg); - } - - return manifestJSON; -} - -/** - * Returns a manifest.json if it exists, null otherwise - * @param searchDir typically current working directory - * @param filename file to search for - */ -function readManifestJSONFile(searchDir, filename) { - let jsonFilePath, manifestJSON; - - try { - jsonFilePath = find(searchDir, filename); - if (fs.existsSync(jsonFilePath)) { - manifestJSON = JSON.parse(fs.readFileSync(jsonFilePath, 'utf8')); - } - } catch (error) { - console.error(error) - return null; - } - - return manifestJSON; -} - -/** - * Search for provided file path. - * Returns full path when filename is found or null if no file found. - * @param currentPath string of current path - * @param targetFilename filename to match - * @returns full file path string relative to starting path or null - * */ -function find(currentPath, targetFilename) { - // TODO Cache searched paths and check that they haven't been explored already - // This guards against rare edge case of a subdir in the file tree which is - // symlinked back to root or in such a way that creates a cycle. Can also implement - // max depth check. - if (currentPath.endsWith(`/${targetFilename}`)) { - return currentPath; - } - - if (fs.existsSync(currentPath) && fs.lstatSync(currentPath).isDirectory()) { - let foundEntry; - const dirents = fs.readdirSync(currentPath); - for (const entry of dirents) { - if (entry !== 'node_modules') { - let newPath = path.resolve(currentPath, entry); - foundEntry = find(newPath, targetFilename); - if (foundEntry) { - return foundEntry; - } - } - } - return null; - } -} \ No newline at end of file diff --git a/bin/cli/start.js b/bin/cli/start.js deleted file mode 100755 index c3d1bfbe5..000000000 --- a/bin/cli/start.js +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env node -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); -const { main: pkgJSONMain } = JSON.parse(fs.readFileSync('./package.json', 'utf-8')); - -// Run script hook verifies that requirements for running an App in -// in developerMode (via Socket Mode) are met -(function _(cwd, customPath) { - // TODO - Format so that its less miss-able in output - console.log('Preparing local run in developer mode (Socket Mode)'); - // Check required local run tokens - validate(); - - // tries the provided path, then package.json main, then defaults to index.js in the current - // working directory - const pkgJSONDefault = 'index.js'; - const fullPath = path.resolve(cwd, customPath ? customPath : pkgJSONMain ? pkgJSONMain : pkgJSONDefault); - console.log(fullPath); - - // Kick off a subprocess to run the app in development mode - const app = spawn('node', [`${fullPath}`]); - app.stdout.setEncoding('utf-8'); - app.stdout.on('data', (data) => { - process.stdout.write(data); - }); - app.stderr.on('data', (data) => { - process.stderr.write(data); - }); - - app.on('close', (code) => { - console.log(`bolt-app local run exited with code ${code}`); - }); -}(process.cwd(), process.env.SLACK_CLI_CUSTOM_FILE_PATH)); - -function validate() { - if (!process.env.SLACK_CLI_XOXB) { - throw new Error('Missing local run bot token. Please see slack-cli maintainers to troubleshoot.'); - } - if (!process.env.SLACK_CLI_XAPP) { - throw new Error('Missing local run app token. Please see slack-cli maintainers to troubleshoot'); - } -} \ No newline at end of file diff --git a/package.json b/package.json index 55d13921c..9312fd59c 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,6 @@ "test:types": "tsd", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, - "bin": { - "slack-cli-get-hooks": "./bin/cli/get-hooks.js", - "slack-cli-get-manifest": "./bin/cli/get-manifest.js", - "slack-cli-check-update": "./bin/cli/check-update.js", - "slack-cli-start": "./bin/cli/start.js" - }, "repository": "slackapi/bolt", "homepage": "https://slack.dev/bolt-js", "bugs": { From 1b7987369894557e04028f5f98d1aaa974a3e3cd Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Wed, 24 Jan 2024 16:18:34 -0800 Subject: [PATCH 06/13] Incorporate PR comments --- src/App.ts | 45 +++--- ...{WorkflowFunction.ts => CustomFunction.ts} | 142 ++++++++---------- src/errors.ts | 6 +- src/types/events/base-events.ts | 48 +++--- 4 files changed, 110 insertions(+), 131 deletions(-) rename src/{WorkflowFunction.ts => CustomFunction.ts} (53%) diff --git a/src/App.ts b/src/App.ts index 6a98ef6ac..9902de838 100644 --- a/src/App.ts +++ b/src/App.ts @@ -61,7 +61,7 @@ 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, WorkflowFunction, WorkflowFunctionMiddleware } from './WorkflowFunction'; +import { FunctionCompleteFn, FunctionFailFn, CustomFunction, CustomFunctionMiddleware } from './CustomFunction'; // 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 @@ -337,7 +337,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> this.errorHandler = defaultErrorHandler(this.logger) as AnyErrorHandler; this.extendedErrorHandler = extendedErrorHandler; - // Override token with functionBotToken in function-related handlers + // Override token with functionBotAccessToken in function-related handlers this.attachFunctionToken = attachFunctionToken; /* ------------------------ Set client options ------------------------*/ @@ -526,10 +526,10 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> } /** - * Register WorkflowFunction middleware + * Register CustomFunction middleware */ - public function(callbackId: string, ...listeners: WorkflowFunctionMiddleware): this { - const fn = new WorkflowFunction(callbackId, listeners); + public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this { + const fn = new CustomFunction(callbackId, listeners); const m = fn.getMiddleware(); this.middleware.push(m); return this; @@ -964,10 +964,12 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> retryReason: event.retryReason, }; + // Extract function-related information and augment to context + const { functionExecutionId, functionBotAccessToken } = extractFunctionContext(body); + if (functionExecutionId) { context.functionExecutionId = functionExecutionId; } + if (this.attachFunctionToken) { - const { functionExecutionId, functionBotToken } = extractFunctionContext(body); - if (functionExecutionId) { context.functionExecutionId = functionExecutionId; } - if (functionBotToken) { context.functionBotToken = functionBotToken; } + if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } } // Factory for say() utility @@ -1082,23 +1084,10 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> let { client } = this; const token = selectToken(context); - // TODO :: WorkflowFunction owns these same utilities. Rework TS to allow for reuse. - // Set complete() and fail() utilities for function-related interactivity + // Add complete() and fail() utilities for function-related interactivity if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) { - listenerArgs.complete = (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ - token: context.functionBotToken, - outputs: params.outputs || {}, - function_execution_id: context.functionExecutionId, - }); - listenerArgs.fail = (params: Parameters<FunctionFailFn>[0]) => { - const { error } = params ?? {}; - - return client.functions.completeError({ - token: context.functionBotToken, - error, - function_execution_id: context.functionExecutionId, - }); - }; + listenerArgs.complete = CustomFunction.createFunctionComplete(context, client); + listenerArgs.fail = CustomFunction.createFunctionFail(context, client); } if (token !== undefined) { @@ -1609,21 +1598,21 @@ function escapeHtml(input: string | undefined | null): string { function extractFunctionContext(body: StringIndexed) { let functionExecutionId; - let functionBotToken; + let functionBotAccessToken; // function_executed event if (body.event && body.event.type === 'function_executed' && body.event.function_execution_id) { functionExecutionId = body.event.function_execution_id; - functionBotToken = body.event.bot_access_token; + functionBotAccessToken = body.event.bot_access_token; } // interactivity (block_actions) if (body.function_data) { functionExecutionId = body.function_data.execution_id; - functionBotToken = body.bot_access_token; + functionBotAccessToken = body.bot_access_token; } - return { functionExecutionId, functionBotToken }; + return { functionExecutionId, functionBotAccessToken }; } // ---------------------------- diff --git a/src/WorkflowFunction.ts b/src/CustomFunction.ts similarity index 53% rename from src/WorkflowFunction.ts rename to src/CustomFunction.ts index 848cd7a48..b20276535 100644 --- a/src/WorkflowFunction.ts +++ b/src/CustomFunction.ts @@ -12,43 +12,43 @@ import { Context, } from './types'; import processMiddleware from './middleware/process'; -import { WorkflowFunctionInitializationError } from './errors'; +import { CustomFunctionInitializationError } from './errors'; /** Interfaces */ -export interface FunctionCompleteArguments { +interface FunctionCompleteArguments { outputs?: { [key: string]: any; }; } -export interface FunctionFailArguments { - error: string; -} - export interface FunctionCompleteFn { (params?: FunctionCompleteArguments): Promise<FunctionsCompleteSuccessResponse>; } +interface FunctionFailArguments { + error: string; +} + export interface FunctionFailFn { (params: FunctionFailArguments): Promise<FunctionsCompleteErrorResponse>; } -export interface WorkflowFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { +export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { complete: FunctionCompleteFn; fail: FunctionFailFn; } /** Types */ -export type SlackWorkflowFunctionMiddlewareArgs = WorkflowFunctionExecuteMiddlewareArgs; +export type SlackCustomFunctionMiddlewareArgs = CustomFunctionExecuteMiddlewareArgs; -export type WorkflowFunctionExecuteMiddleware = Middleware<WorkflowFunctionExecuteMiddlewareArgs>; +type CustomFunctionExecuteMiddleware = Middleware<CustomFunctionExecuteMiddlewareArgs>; -export type WorkflowFunctionMiddleware = WorkflowFunctionExecuteMiddleware[]; +export type CustomFunctionMiddleware = CustomFunctionExecuteMiddleware[]; -export type AllWorkflowFunctionMiddlewareArgs - <T extends SlackWorkflowFunctionMiddlewareArgs = SlackWorkflowFunctionMiddlewareArgs> = T & AllMiddlewareArgs; +export type AllCustomFunctionMiddlewareArgs + <T extends SlackCustomFunctionMiddlewareArgs = SlackCustomFunctionMiddlewareArgs> = T & AllMiddlewareArgs; /** Constants */ @@ -56,15 +56,15 @@ const VALID_PAYLOAD_TYPES = new Set(['function_executed']); /** Class */ -export class WorkflowFunction { +export class CustomFunction { /** Function callback_id */ public callbackId: string; - private middleware: WorkflowFunctionMiddleware; + private middleware: CustomFunctionMiddleware; public constructor( callbackId: string, - middleware: WorkflowFunctionMiddleware, + middleware: CustomFunctionMiddleware, ) { validate(callbackId, middleware); @@ -81,35 +81,69 @@ export class WorkflowFunction { }; } - private matchesConstraints(args: SlackWorkflowFunctionMiddlewareArgs): boolean { + private matchesConstraints(args: SlackCustomFunctionMiddlewareArgs): boolean { return args.payload.function.callback_id === this.callbackId; } - private async processEvent(args: AllWorkflowFunctionMiddlewareArgs): Promise<void> { + private async processEvent(args: AllCustomFunctionMiddlewareArgs): Promise<void> { const functionArgs = prepareFunctionArgs(args); const stepMiddleware = this.getStepMiddleware(); return processStepMiddleware(functionArgs, stepMiddleware); } - private getStepMiddleware(): WorkflowFunctionMiddleware { + private getStepMiddleware(): CustomFunctionMiddleware { return this.middleware; } + + /** + * Factory for `complete()` utility + * @param args function_executed event + */ + public static createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn { + const token = selectToken(context); + const { functionExecutionId } = context; + + return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ + token, + outputs: params.outputs || {}, + function_execution_id: functionExecutionId, + }); + } + + /** + * Factory for `fail()` utility + * @param args function_executed event + */ + public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn { + const token = selectToken(context); + + return (params: Parameters<FunctionFailFn>[0]) => { + const { error } = params ?? {}; + const { functionExecutionId } = context; + + return client.functions.completeError({ + token, + error, + function_execution_id: functionExecutionId, + }); + }; + } } /** Helper Functions */ -export function validate(callbackId: string, listeners: WorkflowFunctionMiddleware): void { +export function validate(callbackId: string, listeners: CustomFunctionMiddleware): void { // Ensure callbackId is valid if (typeof callbackId !== 'string') { - const errorMsg = 'WorkflowFunction expects a callback_id as the first argument'; - throw new WorkflowFunctionInitializationError(errorMsg); + const errorMsg = 'CustomFunction expects a callback_id as the first argument'; + throw new CustomFunctionInitializationError(errorMsg); } // Ensure all listeners are functions listeners.forEach((listener) => { if (!(listener instanceof Function)) { - const errorMsg = 'All WorkflowFunction listeners must be functions'; - throw new WorkflowFunctionInitializationError(errorMsg); + const errorMsg = 'All CustomFunction listeners must be functions'; + throw new CustomFunctionInitializationError(errorMsg); } }); } @@ -119,8 +153,8 @@ export function validate(callbackId: string, listeners: WorkflowFunctionMiddlewa * @param args workflow_step_edit action */ export async function processStepMiddleware( - args: AllWorkflowFunctionMiddlewareArgs, - middleware: WorkflowFunctionMiddleware, + args: AllCustomFunctionMiddlewareArgs, + middleware: CustomFunctionMiddleware, ): Promise<void> { const { context, client, logger } = args; const callbacks = [...middleware] as Middleware<AnyMiddlewareArgs>[]; @@ -134,59 +168,13 @@ export async function processStepMiddleware( } } -export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllWorkflowFunctionMiddlewareArgs { +function isFunctionEvent(args: AnyMiddlewareArgs): args is AllCustomFunctionMiddlewareArgs { return VALID_PAYLOAD_TYPES.has(args.payload.type); } function selectToken(context: Context): string | undefined { // If attachFunctionToken = false, fallback to botToken or userToken - return context.functionBotToken ? context.functionBotToken : context.botToken || context.userToken; -} - -/** - * Factory for `complete()` utility - * @param args function_executed event - */ -export function createFunctionComplete( - args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>, -): FunctionCompleteFn { - const { - context, - client, - payload: { function_execution_id }, - } = args; - const token = selectToken(context); - - return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ - token, - outputs: params.outputs || {}, - function_execution_id, - }); -} - -/** - * Factory for `fail()` utility - * @param args function_executed event - */ -export function createFunctionFail( - args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>, -): FunctionFailFn { - const { - context, - client, - payload: { function_execution_id }, - } = args; - const token = selectToken(context); - - return (params: Parameters<FunctionFailFn>[0]) => { - const { error } = params ?? {}; - - return client.functions.completeError({ - token, - error, - function_execution_id, - }); - }; + return context.functionBotAccessToken ? context.functionBotAccessToken : context.botToken || context.userToken; } /** @@ -195,20 +183,20 @@ export function createFunctionFail( * - events will *not* continue down global middleware chain to subsequent listeners * 2. augments args with step lifecycle-specific properties/utilities * */ -export function prepareFunctionArgs(args: any): AllWorkflowFunctionMiddlewareArgs { +function prepareFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs { const { next: _next, ...functionArgs } = args; const preparedArgs: any = { ...functionArgs }; const token = selectToken(functionArgs.context); - // Making calls with a functionBotToken establishes continuity between + // Making calls with a functionBotAccessToken establishes continuity between // a function_executed event and subsequent interactive events (actions) const client = new WebClient(token, { ...functionArgs.client }); preparedArgs.client = client; // Utility args preparedArgs.inputs = preparedArgs.event.inputs; - preparedArgs.complete = createFunctionComplete(preparedArgs); - preparedArgs.fail = createFunctionFail(preparedArgs); + preparedArgs.complete = CustomFunction.createFunctionComplete(preparedArgs.context, client); + preparedArgs.fail = CustomFunction.createFunctionFail(preparedArgs.context, client); return preparedArgs; } diff --git a/src/errors.ts b/src/errors.ts index 3f7db700b..f5a1505cb 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -35,7 +35,7 @@ export enum ErrorCode { WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', - WorkflowFunctionInitializationError = 'slack_bolt_workflow_function_initialization_error', + CustomFunctionInitializationError = 'slack_bolt_workflow_function_initialization_error', } export class UnknownError extends Error implements CodedError { @@ -141,6 +141,6 @@ export class WorkflowStepInitializationError extends Error implements CodedError public code = ErrorCode.WorkflowStepInitializationError; } -export class WorkflowFunctionInitializationError extends Error implements CodedError { - public code = ErrorCode.WorkflowFunctionInitializationError; +export class CustomFunctionInitializationError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionInitializationError; } diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index cb9cd793d..8129a584e 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -427,37 +427,39 @@ export interface FileUnsharedEvent { } export interface FunctionParams { - type?: string, - name?: string, - description?: string, - title?: string, - is_required?: boolean, + type?: string; + name?: string; + description?: string; + title?: string; + is_required?: boolean; } -export interface FunctionInputValues { +export interface FunctionInputs { [key: string]: unknown; } -export type FunctionOutputValues = FunctionInputValues; +export type FunctionOutputValues = FunctionInputs; -// TODO :: Update this with new payload info export interface FunctionExecutedEvent { - type: 'function_executed', + type: 'function_executed'; function: { - id: string, - callback_id: string, - title: string, - description: string, - type: string, - input_parameters: FunctionParams[], - output_parameters: FunctionParams[], - app_id: string, - date_updated: number, - }, - function_execution_id: string, - inputs: FunctionInputValues, - workflow_token: string, // xwfp-xxxxxxxxxxx - event_ts: string, + id: string; + callback_id: string; + title: string; + description: string; + type: string; + input_parameters: FunctionParams[]; + output_parameters: FunctionParams[]; + app_id: string; + date_created: number; + date_updated: number; + date_deleted: number + }; + inputs: FunctionInputs; + function_execution_id: string; + workflow_execution_id: string; + event_ts: string; + bot_access_token: string; } export interface GridMigrationFinishedEvent { From 630f188d83dfe07a1f1b0a34bc6aebde15fe3660 Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Thu, 25 Jan 2024 15:24:07 -0800 Subject: [PATCH 07/13] Add CustomFunction test + adjust naming --- src/CustomFunction.spec.ts | 237 +++++++++++++++++++++++++++++++++++++ src/CustomFunction.ts | 63 +++++----- 2 files changed, 273 insertions(+), 27 deletions(-) create mode 100644 src/CustomFunction.spec.ts diff --git a/src/CustomFunction.spec.ts b/src/CustomFunction.spec.ts new file mode 100644 index 000000000..5b5a5429f --- /dev/null +++ b/src/CustomFunction.spec.ts @@ -0,0 +1,237 @@ +import 'mocha'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import rewiremock from 'rewiremock'; +import { + CustomFunction, + SlackCustomFunctionMiddlewareArgs, + AllCustomFunctionMiddlewareArgs, + CustomFunctionMiddleware, + CustomFunctionExecuteMiddlewareArgs, +} from './CustomFunction'; +import { Override } from './test-helpers'; +import { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware } from './types'; +import { CustomFunctionInitializationError } from './errors'; + +async function importCustomFunction(overrides: Override = {}): Promise<typeof import('./CustomFunction')> { + return rewiremock.module(() => import('./CustomFunction'), overrides); +} + +const MOCK_FN = async () => {}; +const MOCK_FN_2 = async () => {}; + +const MOCK_MIDDLEWARE_SINGLE = [MOCK_FN]; +const MOCK_MIDDLEWARE_MULTIPLE = [MOCK_FN, MOCK_FN_2]; + +describe('CustomFunction class', () => { + describe('constructor', () => { + it('should accept single function as middleware', async () => { + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_SINGLE); + assert.isNotNull(fn); + }); + + it('should accept multiple functions as middleware', async () => { + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE); + assert.isNotNull(fn); + }); + }); + + describe('getMiddleware', () => { + it('should not call next if a function_executed event', async () => { + const fn = new CustomFunction('test_executed_callback_id', MOCK_MIDDLEWARE_SINGLE); + const middleware = fn.getMiddleware(); + const fakeEditArgs = createFakeFunctionExecutedEvent() as unknown as + SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + + const fakeNext = sinon.spy(); + fakeEditArgs.next = fakeNext; + + await middleware(fakeEditArgs); + + assert(fakeNext.notCalled); + }); + + it('should call next if valid custom function but mismatched callback_id', async () => { + const fn = new CustomFunction('bad_executed_callback_id', MOCK_MIDDLEWARE_SINGLE); + const middleware = fn.getMiddleware(); + const fakeEditArgs = createFakeFunctionExecutedEvent() as unknown as + SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + + const fakeNext = sinon.spy(); + fakeEditArgs.next = fakeNext; + + await middleware(fakeEditArgs); + + assert(fakeNext.called); + }); + + it('should call next if not a workflow step event', async () => { + const fn = new CustomFunction('test_view_callback_id', MOCK_MIDDLEWARE_SINGLE); + const middleware = fn.getMiddleware(); + const fakeViewArgs = createFakeViewEvent() as unknown as + SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + + const fakeNext = sinon.spy(); + fakeViewArgs.next = fakeNext; + + await middleware(fakeViewArgs); + + assert(fakeNext.called); + }); + }); + + describe('validate', () => { + it('should throw an error if callback_id is not valid', async () => { + const { validate } = await importCustomFunction(); + + // intentionally casting to string to trigger failure + const badId = {} as string; + const validationFn = () => validate(badId, MOCK_MIDDLEWARE_SINGLE); + + const expectedMsg = 'CustomFunction expects a callback_id as the first argument'; + assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + }); + + it('should throw an error if middleware is not a function or array', async () => { + const { validate } = await importCustomFunction(); + + // intentionally casting to CustomFunctionMiddleware to trigger failure + const badConfig = '' as unknown as CustomFunctionMiddleware; + + const validationFn = () => validate('callback_id', badConfig); + const expectedMsg = 'CustomFunction expects a function or array of functions as the second argument'; + assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + }); + + it('should throw an error if middleware is not a single callback or an array of callbacks', async () => { + const { validate } = await importCustomFunction(); + + // intentionally casting to CustomFunctionMiddleware to trigger failure + const badMiddleware = [ + async () => {}, + 'not-a-function', + ] as unknown as CustomFunctionMiddleware; + + const validationFn = () => validate('callback_id', badMiddleware); + const expectedMsg = 'All CustomFunction middleware must be functions'; + assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + }); + }); + + describe('isFunctionEvent', () => { + it('should return true if recognized function_executed payload type', async () => { + const fakeExecuteArgs = createFakeFunctionExecutedEvent() as unknown as SlackCustomFunctionMiddlewareArgs + & AllMiddlewareArgs; + + const { isFunctionEvent } = await importCustomFunction(); + const eventIsFunctionExcuted = isFunctionEvent(fakeExecuteArgs); + + assert.isTrue(eventIsFunctionExcuted); + }); + + it('should return false if not a function_executed payload type', async () => { + const fakeExecutedEvent = createFakeFunctionExecutedEvent() as unknown as AnyMiddlewareArgs; + fakeExecutedEvent.payload.type = 'invalid_type'; + + const { isFunctionEvent } = await importCustomFunction(); + const eventIsFunctionExecuted = isFunctionEvent(fakeExecutedEvent); + + assert.isFalse(eventIsFunctionExecuted); + }); + }); + + describe('enrichFunctionArgs', () => { + it('should remove next() from all original event args', async () => { + const fakeExecutedEvent = createFakeFunctionExecutedEvent() as unknown as AnyMiddlewareArgs; + + const { enrichFunctionArgs } = await importCustomFunction(); + const executeFunctionArgs = enrichFunctionArgs(fakeExecutedEvent); + + assert.notExists(executeFunctionArgs.next); + }); + + it('should augment function_executed args with inputs, complete, and fail', async () => { + const fakeArgs = createFakeFunctionExecutedEvent(); + + const { enrichFunctionArgs } = await importCustomFunction(); + const functionArgs = enrichFunctionArgs(fakeArgs); + + assert.exists(functionArgs.inputs); + assert.exists(functionArgs.complete); + assert.exists(functionArgs.fail); + }); + }); + + describe('custom function utility functions', () => { + it('complete should call functions.completeSuccess', async () => { + // TODO + }); + + it('fail should call functions.completeError', async () => { + // TODO + }); + + it('inputs should map to function payload inputs', async () => { + const fakeExecuteArgs = createFakeFunctionExecutedEvent() as unknown as AllCustomFunctionMiddlewareArgs; + + const { enrichFunctionArgs } = await importCustomFunction(); + const enrichedArgs = enrichFunctionArgs(fakeExecuteArgs); + + assert.isTrue(enrichedArgs.inputs === fakeExecuteArgs.event.inputs); + }); + }); + + describe('processFunctionMiddleware', () => { + it('should call each callback in user-provided middleware', async () => { + const { ...fakeArgs } = createFakeFunctionExecutedEvent() as unknown as AllCustomFunctionMiddlewareArgs; + const { processFunctionMiddleware } = await importCustomFunction(); + + const fn1 = sinon.spy((async ({ next: continuation }) => { + await continuation(); + }) as Middleware<CustomFunctionExecuteMiddlewareArgs>); + const fn2 = sinon.spy(async () => {}); + const fakeMiddleware = [fn1, fn2] as CustomFunctionMiddleware; + + await processFunctionMiddleware(fakeArgs, fakeMiddleware); + + assert(fn1.called); + assert(fn2.called); + }); + }); +}); + +function createFakeFunctionExecutedEvent() { + return { + event: { + inputs: { message: 'test123', recipient: 'U012345' }, + }, + payload: { + type: 'function_executed', + function: { + callback_id: 'test_executed_callback_id', + }, + inputs: { message: 'test123', recipient: 'U012345' }, + bot_access_token: 'xwfp-123', + }, + context: { + functionBotAccessToken: 'xwfp-123', + }, + }; +} + +function createFakeViewEvent() { + return { + body: { + callback_id: 'test_view_callback_id', + trigger_id: 'test_view_trigger_id', + workflow_step: { + workflow_step_edit_id: '', + }, + }, + payload: { + type: 'view_submission', + callback_id: 'test_view_callback_id', + }, + context: {}, + }; +} diff --git a/src/CustomFunction.ts b/src/CustomFunction.ts index b20276535..8f52c7e97 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -10,6 +10,7 @@ import { AnyMiddlewareArgs, SlackEventMiddlewareArgs, Context, + FunctionExecutedEvent, } from './types'; import processMiddleware from './middleware/process'; import { CustomFunctionInitializationError } from './errors'; @@ -35,6 +36,7 @@ export interface FunctionFailFn { } export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { + inputs: FunctionExecutedEvent['inputs']; complete: FunctionCompleteFn; fail: FunctionFailFn; } @@ -43,9 +45,9 @@ export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewar export type SlackCustomFunctionMiddlewareArgs = CustomFunctionExecuteMiddlewareArgs; -type CustomFunctionExecuteMiddleware = Middleware<CustomFunctionExecuteMiddlewareArgs>; +type CustomFunctionExecuteMiddleware = Middleware<CustomFunctionExecuteMiddlewareArgs>[]; -export type CustomFunctionMiddleware = CustomFunctionExecuteMiddleware[]; +export type CustomFunctionMiddleware = Middleware<CustomFunctionExecuteMiddlewareArgs>[]; export type AllCustomFunctionMiddlewareArgs <T extends SlackCustomFunctionMiddlewareArgs = SlackCustomFunctionMiddlewareArgs> = T & AllMiddlewareArgs; @@ -64,7 +66,7 @@ export class CustomFunction { public constructor( callbackId: string, - middleware: CustomFunctionMiddleware, + middleware: CustomFunctionExecuteMiddleware, ) { validate(callbackId, middleware); @@ -86,12 +88,12 @@ export class CustomFunction { } private async processEvent(args: AllCustomFunctionMiddlewareArgs): Promise<void> { - const functionArgs = prepareFunctionArgs(args); - const stepMiddleware = this.getStepMiddleware(); - return processStepMiddleware(functionArgs, stepMiddleware); + const functionArgs = enrichFunctionArgs(args); + const functionMiddleware = this.getFunctionMiddleware(); + return processFunctionMiddleware(functionArgs, functionMiddleware); } - private getStepMiddleware(): CustomFunctionMiddleware { + private getFunctionMiddleware(): CustomFunctionMiddleware { return this.middleware; } @@ -131,28 +133,35 @@ export class CustomFunction { } /** Helper Functions */ - -export function validate(callbackId: string, listeners: CustomFunctionMiddleware): void { +export function validate(callbackId: string, middleware: CustomFunctionExecuteMiddleware): void { // Ensure callbackId is valid if (typeof callbackId !== 'string') { const errorMsg = 'CustomFunction expects a callback_id as the first argument'; throw new CustomFunctionInitializationError(errorMsg); } - // Ensure all listeners are functions - listeners.forEach((listener) => { - if (!(listener instanceof Function)) { - const errorMsg = 'All CustomFunction listeners must be functions'; - throw new CustomFunctionInitializationError(errorMsg); - } - }); + // Ensure middleware argument is either a function or an array + if (typeof middleware !== 'function' && !Array.isArray(middleware)) { + const errorMsg = 'CustomFunction expects a function or array of functions as the second argument'; + throw new CustomFunctionInitializationError(errorMsg); + } + + // Ensure array includes only functions + if (Array.isArray(middleware)) { + middleware.forEach((fn) => { + if (!(fn instanceof Function)) { + const errorMsg = 'All CustomFunction middleware must be functions'; + throw new CustomFunctionInitializationError(errorMsg); + } + }); + } } /** - * `processStepMiddleware()` invokes each callback for lifecycle event + * `processFunctionMiddleware()` invokes each callback for lifecycle event * @param args workflow_step_edit action */ -export async function processStepMiddleware( +export async function processFunctionMiddleware( args: AllCustomFunctionMiddlewareArgs, middleware: CustomFunctionMiddleware, ): Promise<void> { @@ -168,7 +177,7 @@ export async function processStepMiddleware( } } -function isFunctionEvent(args: AnyMiddlewareArgs): args is AllCustomFunctionMiddlewareArgs { +export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllCustomFunctionMiddlewareArgs { return VALID_PAYLOAD_TYPES.has(args.payload.type); } @@ -178,25 +187,25 @@ function selectToken(context: Context): string | undefined { } /** - * `prepareFunctionArgs()` takes in a function's args and: + * `enrichFunctionArgs()` takes in a function's args and: * 1. removes the next() passed in from App-level middleware processing * - events will *not* continue down global middleware chain to subsequent listeners * 2. augments args with step lifecycle-specific properties/utilities * */ -function prepareFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs { +export function enrichFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs { const { next: _next, ...functionArgs } = args; - const preparedArgs: any = { ...functionArgs }; + const enrichedArgs: any = { ...functionArgs }; const token = selectToken(functionArgs.context); // Making calls with a functionBotAccessToken establishes continuity between // a function_executed event and subsequent interactive events (actions) const client = new WebClient(token, { ...functionArgs.client }); - preparedArgs.client = client; + enrichedArgs.client = client; // Utility args - preparedArgs.inputs = preparedArgs.event.inputs; - preparedArgs.complete = CustomFunction.createFunctionComplete(preparedArgs.context, client); - preparedArgs.fail = CustomFunction.createFunctionFail(preparedArgs.context, client); + enrichedArgs.inputs = enrichedArgs.event.inputs; + enrichedArgs.complete = CustomFunction.createFunctionComplete(enrichedArgs.context, client); + enrichedArgs.fail = CustomFunction.createFunctionFail(enrichedArgs.context, client); - return preparedArgs; + return enrichedArgs; } From caec4b1356f8b584e2a6e677ef89bb1156ba94ad Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Thu, 25 Jan 2024 15:33:16 -0800 Subject: [PATCH 08/13] Update error description --- src/errors.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/errors.ts b/src/errors.ts index f5a1505cb..19e54e39a 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -35,7 +35,7 @@ export enum ErrorCode { WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', - CustomFunctionInitializationError = 'slack_bolt_workflow_function_initialization_error', + CustomFunctionInitializationError = 'slack_bolt_custom_function_initialization_error', } export class UnknownError extends Error implements CodedError { From 247b4c759102ae003ff66221441113f2be7cc8eb Mon Sep 17 00:00:00 2001 From: Ethan Zimbelman <ethan.zimbelman@me.com> Date: Thu, 25 Jan 2024 16:20:52 -0800 Subject: [PATCH 09/13] Bump the @slack/web-api version to v6.12.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9312fd59c..00f062233 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@slack/oauth": "^2.6.2", "@slack/socket-mode": "^1.3.3", "@slack/types": "^2.11.0", - "@slack/web-api": "^6.11.2", + "@slack/web-api": "^6.12.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", From 11a42c09b5ef81b98c26896bdb0b3a6d0cc267b1 Mon Sep 17 00:00:00 2001 From: Ethan Zimbelman <ethan.zimbelman@me.com> Date: Thu, 25 Jan 2024 16:25:10 -0800 Subject: [PATCH 10/13] v3.17.1-customFunctionBeta.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 00f062233..68df7d8a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slack/bolt", - "version": "3.17.1", + "version": "3.17.1-customFunctionBeta.0", "description": "A framework for building Slack apps, fast.", "author": "Slack Technologies, LLC", "license": "MIT", From bea900d20c488e81419e5563feb5c8e9469110d7 Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Mon, 12 Aug 2024 19:20:01 +0000 Subject: [PATCH 11/13] Fixes and polish for stable release (#2128) Co-authored-by: Fil Maj <maj.fil@gmail.com> --- package.json | 3 +- src/App.ts | 22 +++-- src/CustomFunction.spec.ts | 144 +++++++++++++++++++++++--------- src/CustomFunction.ts | 36 +++++--- src/errors.ts | 10 +++ src/middleware/builtin.ts | 24 ++++-- src/middleware/process.ts | 2 +- src/types/actions/index.ts | 7 +- src/types/events/base-events.ts | 8 +- src/types/events/index.ts | 8 +- src/types/helpers.ts | 2 + src/types/middleware.ts | 22 ++++- tsconfig.eslint.json | 2 +- types-tests/event.test-d.ts | 20 ++--- 14 files changed, 222 insertions(+), 88 deletions(-) diff --git a/package.json b/package.json index 2ac6247ae..40c180517 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "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 lint && npm run mocha && npm run test:types", + "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", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, diff --git a/src/App.ts b/src/App.ts index 9902de838..ac4cbefd7 100644 --- a/src/App.ts +++ b/src/App.ts @@ -54,6 +54,7 @@ import { SlashCommand, WorkflowStepEdit, SlackOptions, + FunctionInputs, } from './types'; import { IncomingEventType, getTypeAndConversation, assertNever, isBodyWithTypeEnterpriseInstall, isEventTypeToSkipAuthorize } from './helpers'; import { CodedError, asCodedError, AppInitializationError, MultipleListenerError, ErrorCode, InvalidCustomPropertyError } from './errors'; @@ -502,6 +503,10 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> } } + public get webClientOptions(): WebClientOptions { + return this.clientOptions; + } + /** * Register a new middleware, processed in the order registered. * @@ -529,7 +534,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> * Register CustomFunction middleware */ public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this { - const fn = new CustomFunction(callbackId, listeners); + const fn = new CustomFunction(callbackId, listeners, this.webClientOptions); const m = fn.getMiddleware(); this.middleware.push(m); return this; @@ -964,9 +969,12 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> retryReason: event.retryReason, }; - // Extract function-related information and augment to context - const { functionExecutionId, functionBotAccessToken } = extractFunctionContext(body); - if (functionExecutionId) { context.functionExecutionId = functionExecutionId; } + // Extract function-related information and augment context + const { functionExecutionId, functionBotAccessToken, functionInputs } = extractFunctionContext(body); + if (functionExecutionId) { + context.functionExecutionId = functionExecutionId; + if (functionInputs) { context.functionInputs = functionInputs; } + } if (this.attachFunctionToken) { if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } @@ -1029,6 +1037,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> ack?: AckFn<any>; complete?: FunctionCompleteFn; fail?: FunctionFailFn; + inputs?: FunctionInputs; } = { body: bodyArg, payload, @@ -1088,6 +1097,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) { listenerArgs.complete = CustomFunction.createFunctionComplete(context, client); listenerArgs.fail = CustomFunction.createFunctionFail(context, client); + listenerArgs.inputs = context.functionInputs; } if (token !== undefined) { @@ -1599,6 +1609,7 @@ function escapeHtml(input: string | undefined | null): string { function extractFunctionContext(body: StringIndexed) { let functionExecutionId; let functionBotAccessToken; + let functionInputs; // function_executed event if (body.event && body.event.type === 'function_executed' && body.event.function_execution_id) { @@ -1610,9 +1621,10 @@ function extractFunctionContext(body: StringIndexed) { if (body.function_data) { functionExecutionId = body.function_data.execution_id; functionBotAccessToken = body.bot_access_token; + functionInputs = body.function_data.inputs; } - return { functionExecutionId, functionBotAccessToken }; + return { functionExecutionId, functionBotAccessToken, functionInputs }; } // ---------------------------- diff --git a/src/CustomFunction.spec.ts b/src/CustomFunction.spec.ts index 5b5a5429f..24dc00059 100644 --- a/src/CustomFunction.spec.ts +++ b/src/CustomFunction.spec.ts @@ -2,6 +2,7 @@ import 'mocha'; import { assert } from 'chai'; import sinon from 'sinon'; import rewiremock from 'rewiremock'; +import { WebClient } from '@slack/web-api'; import { CustomFunction, SlackCustomFunctionMiddlewareArgs, @@ -9,8 +10,8 @@ import { CustomFunctionMiddleware, CustomFunctionExecuteMiddlewareArgs, } from './CustomFunction'; -import { Override } from './test-helpers'; -import { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware } from './types'; +import { createFakeLogger, Override } from './test-helpers'; +import { AllMiddlewareArgs, Middleware } from './types'; import { CustomFunctionInitializationError } from './errors'; async function importCustomFunction(overrides: Override = {}): Promise<typeof import('./CustomFunction')> { @@ -26,36 +27,35 @@ const MOCK_MIDDLEWARE_MULTIPLE = [MOCK_FN, MOCK_FN_2]; describe('CustomFunction class', () => { describe('constructor', () => { it('should accept single function as middleware', async () => { - const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_SINGLE); + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); assert.isNotNull(fn); }); it('should accept multiple functions as middleware', async () => { - const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE); + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE, {}); assert.isNotNull(fn); }); }); describe('getMiddleware', () => { it('should not call next if a function_executed event', async () => { - const fn = new CustomFunction('test_executed_callback_id', MOCK_MIDDLEWARE_SINGLE); + const cbId = 'test_executed_callback_id'; + const fn = new CustomFunction(cbId, MOCK_MIDDLEWARE_SINGLE, {}); const middleware = fn.getMiddleware(); - const fakeEditArgs = createFakeFunctionExecutedEvent() as unknown as - SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + const fakeEditArgs = createFakeFunctionExecutedEvent(cbId); const fakeNext = sinon.spy(); fakeEditArgs.next = fakeNext; await middleware(fakeEditArgs); - assert(fakeNext.notCalled); + assert(fakeNext.notCalled, 'next called!'); }); it('should call next if valid custom function but mismatched callback_id', async () => { - const fn = new CustomFunction('bad_executed_callback_id', MOCK_MIDDLEWARE_SINGLE); + const fn = new CustomFunction('bad_executed_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); const middleware = fn.getMiddleware(); - const fakeEditArgs = createFakeFunctionExecutedEvent() as unknown as - SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; + const fakeEditArgs = createFakeFunctionExecutedEvent(); const fakeNext = sinon.spy(); fakeEditArgs.next = fakeNext; @@ -65,8 +65,8 @@ describe('CustomFunction class', () => { assert(fakeNext.called); }); - it('should call next if not a workflow step event', async () => { - const fn = new CustomFunction('test_view_callback_id', MOCK_MIDDLEWARE_SINGLE); + 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; @@ -120,8 +120,7 @@ describe('CustomFunction class', () => { describe('isFunctionEvent', () => { it('should return true if recognized function_executed payload type', async () => { - const fakeExecuteArgs = createFakeFunctionExecutedEvent() as unknown as SlackCustomFunctionMiddlewareArgs - & AllMiddlewareArgs; + const fakeExecuteArgs = createFakeFunctionExecutedEvent(); const { isFunctionEvent } = await importCustomFunction(); const eventIsFunctionExcuted = isFunctionEvent(fakeExecuteArgs); @@ -130,7 +129,8 @@ describe('CustomFunction class', () => { }); it('should return false if not a function_executed payload type', async () => { - const fakeExecutedEvent = createFakeFunctionExecutedEvent() as unknown as AnyMiddlewareArgs; + const fakeExecutedEvent = createFakeFunctionExecutedEvent(); + // @ts-expect-error expected invalid payload type fakeExecutedEvent.payload.type = 'invalid_type'; const { isFunctionEvent } = await importCustomFunction(); @@ -142,10 +142,10 @@ describe('CustomFunction class', () => { describe('enrichFunctionArgs', () => { it('should remove next() from all original event args', async () => { - const fakeExecutedEvent = createFakeFunctionExecutedEvent() as unknown as AnyMiddlewareArgs; + const fakeExecutedEvent = createFakeFunctionExecutedEvent(); const { enrichFunctionArgs } = await importCustomFunction(); - const executeFunctionArgs = enrichFunctionArgs(fakeExecutedEvent); + const executeFunctionArgs = enrichFunctionArgs(fakeExecutedEvent, {}); assert.notExists(executeFunctionArgs.next); }); @@ -154,7 +154,7 @@ describe('CustomFunction class', () => { const fakeArgs = createFakeFunctionExecutedEvent(); const { enrichFunctionArgs } = await importCustomFunction(); - const functionArgs = enrichFunctionArgs(fakeArgs); + const functionArgs = enrichFunctionArgs(fakeArgs, {}); assert.exists(functionArgs.inputs); assert.exists(functionArgs.complete); @@ -163,19 +163,43 @@ describe('CustomFunction class', () => { }); describe('custom function utility functions', () => { - it('complete should call functions.completeSuccess', async () => { - // TODO + describe('`complete` factory function', () => { + 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); + await complete(); + assert(completeMock.called, 'client.functions.completeSuccess not called!'); + }); + it('should throw if no functionExecutionId present on context', () => { + const client = new WebClient('sometoken'); + assert.throws(() => { + CustomFunction.createFunctionComplete({ isEnterpriseInstall: false }, client); + }); + }); }); - it('fail should call functions.completeError', async () => { - // TODO + describe('`fail` factory function', () => { + 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); + await complete({ error: 'boom' }); + assert(completeMock.called, 'client.functions.completeError not called!'); + }); + it('should throw if no functionExecutionId present on context', () => { + const client = new WebClient('sometoken'); + assert.throws(() => { + CustomFunction.createFunctionFail({ isEnterpriseInstall: false }, client); + }); + }); }); it('inputs should map to function payload inputs', async () => { - const fakeExecuteArgs = createFakeFunctionExecutedEvent() as unknown as AllCustomFunctionMiddlewareArgs; + const fakeExecuteArgs = createFakeFunctionExecutedEvent(); const { enrichFunctionArgs } = await importCustomFunction(); - const enrichedArgs = enrichFunctionArgs(fakeExecuteArgs); + const enrichedArgs = enrichFunctionArgs(fakeExecuteArgs, {}); assert.isTrue(enrichedArgs.inputs === fakeExecuteArgs.event.inputs); }); @@ -183,39 +207,79 @@ describe('CustomFunction class', () => { describe('processFunctionMiddleware', () => { it('should call each callback in user-provided middleware', async () => { - const { ...fakeArgs } = createFakeFunctionExecutedEvent() as unknown as AllCustomFunctionMiddlewareArgs; + const { ...fakeArgs } = createFakeFunctionExecutedEvent(); const { processFunctionMiddleware } = await importCustomFunction(); const fn1 = sinon.spy((async ({ next: continuation }) => { await continuation(); }) as Middleware<CustomFunctionExecuteMiddlewareArgs>); - const fn2 = sinon.spy(async () => {}); + const fn2 = sinon.spy(async () => { + }); const fakeMiddleware = [fn1, fn2] as CustomFunctionMiddleware; await processFunctionMiddleware(fakeArgs, fakeMiddleware); - assert(fn1.called); - assert(fn2.called); + assert(fn1.called, 'first user-provided middleware not called!'); + assert(fn2.called, 'second user-provided middleware not called!'); }); }); }); -function createFakeFunctionExecutedEvent() { +function createFakeFunctionExecutedEvent(callbackId?: string): AllCustomFunctionMiddlewareArgs { + const func = { + type: 'function', + id: 'somefunc', + callback_id: callbackId || 'callback_id', + title: 'My dope function', + input_parameters: [], + output_parameters: [], + app_id: 'A1234', + date_created: 123456, + date_deleted: 0, + date_updated: 123456, + }; + const base = { + bot_access_token: 'xoxb-abcd-1234', + event_ts: '123456.789', + function_execution_id: 'Fx1234', + workflow_execution_id: 'Wf1234', + type: 'function_executed', + } as const; + const inputs = { message: 'test123', recipient: 'U012345' }; + const event = { + function: func, + inputs, + ...base, + } as const; return { - event: { - inputs: { message: 'test123', recipient: 'U012345' }, - }, - payload: { - type: 'function_executed', - function: { - callback_id: 'test_executed_callback_id', - }, - inputs: { message: 'test123', recipient: 'U012345' }, - bot_access_token: 'xwfp-123', + body: { + api_app_id: 'A1234', + event, + event_id: 'E1234', + event_time: 123456, + team_id: 'T1234', + token: 'xoxb-1234', + type: 'event_callback', }, + client: new WebClient('faketoken'), + complete: () => Promise.resolve({ ok: true }), context: { functionBotAccessToken: 'xwfp-123', + functionExecutionId: 'test_executed_callback_id', + isEnterpriseInstall: false, + }, + event, + 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/CustomFunction.ts b/src/CustomFunction.ts index 8f52c7e97..4033bc0d9 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -3,6 +3,7 @@ import { WebClient, FunctionsCompleteErrorResponse, FunctionsCompleteSuccessResponse, + WebClientOptions, } from '@slack/web-api'; import { Middleware, @@ -13,7 +14,7 @@ import { FunctionExecutedEvent, } from './types'; import processMiddleware from './middleware/process'; -import { CustomFunctionInitializationError } from './errors'; +import { CustomFunctionCompleteFailError, CustomFunctionCompleteSuccessError, CustomFunctionInitializationError } from './errors'; /** Interfaces */ @@ -62,14 +63,18 @@ export class CustomFunction { /** Function callback_id */ public callbackId: string; + private appWebClientOptions: WebClientOptions; + private middleware: CustomFunctionMiddleware; public constructor( callbackId: string, middleware: CustomFunctionExecuteMiddleware, + clientOptions: WebClientOptions, ) { validate(callbackId, middleware); + this.appWebClientOptions = clientOptions; this.callbackId = callbackId; this.middleware = middleware; } @@ -88,7 +93,7 @@ export class CustomFunction { } private async processEvent(args: AllCustomFunctionMiddlewareArgs): Promise<void> { - const functionArgs = enrichFunctionArgs(args); + const functionArgs = enrichFunctionArgs(args, this.appWebClientOptions); const functionMiddleware = this.getFunctionMiddleware(); return processFunctionMiddleware(functionArgs, functionMiddleware); } @@ -99,12 +104,16 @@ export class CustomFunction { /** * Factory for `complete()` utility - * @param args function_executed event */ public static createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn { const token = selectToken(context); const { functionExecutionId } = context; + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteSuccessError(errorMsg); + } + return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({ token, outputs: params.outputs || {}, @@ -114,14 +123,18 @@ export class CustomFunction { /** * Factory for `fail()` utility - * @param args function_executed event */ public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn { const token = selectToken(context); + const { functionExecutionId } = context; + + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteFailError(errorMsg); + } return (params: Parameters<FunctionFailFn>[0]) => { const { error } = params ?? {}; - const { functionExecutionId } = context; return client.functions.completeError({ token, @@ -158,8 +171,7 @@ export function validate(callbackId: string, middleware: CustomFunctionExecuteMi } /** - * `processFunctionMiddleware()` invokes each callback for lifecycle event - * @param args workflow_step_edit action + * `processFunctionMiddleware()` invokes each listener middleware */ export async function processFunctionMiddleware( args: AllCustomFunctionMiddlewareArgs, @@ -192,14 +204,16 @@ function selectToken(context: Context): string | undefined { * - events will *not* continue down global middleware chain to subsequent listeners * 2. augments args with step lifecycle-specific properties/utilities * */ -export function enrichFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs { +export function enrichFunctionArgs( + args: AllCustomFunctionMiddlewareArgs, webClientOptions: WebClientOptions, +): AllCustomFunctionMiddlewareArgs { const { next: _next, ...functionArgs } = args; - const enrichedArgs: any = { ...functionArgs }; + const enrichedArgs = { ...functionArgs }; const token = selectToken(functionArgs.context); // Making calls with a functionBotAccessToken establishes continuity between // a function_executed event and subsequent interactive events (actions) - const client = new WebClient(token, { ...functionArgs.client }); + const client = new WebClient(token, webClientOptions); enrichedArgs.client = client; // Utility args @@ -207,5 +221,5 @@ export function enrichFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs { enrichedArgs.complete = CustomFunction.createFunctionComplete(enrichedArgs.context, client); enrichedArgs.fail = CustomFunction.createFunctionFail(enrichedArgs.context, client); - return enrichedArgs; + return enrichedArgs as AllCustomFunctionMiddlewareArgs; // TODO: dangerous casting as it obfuscates missing `next()` } diff --git a/src/errors.ts b/src/errors.ts index c270b9c35..09a5a8b7f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -41,6 +41,8 @@ export enum ErrorCode { WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', CustomFunctionInitializationError = 'slack_bolt_custom_function_initialization_error', + CustomFunctionCompleteSuccessError = 'slack_bolt_custom_function_complete_success_error', + CustomFunctionCompleteFailError = 'slack_bolt_custom_function_complete_fail_error', } export class UnknownError extends Error implements CodedError { @@ -149,3 +151,11 @@ export class WorkflowStepInitializationError extends Error implements CodedError export class CustomFunctionInitializationError extends Error implements CodedError { public code = ErrorCode.CustomFunctionInitializationError; } + +export class CustomFunctionCompleteSuccessError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionCompleteSuccessError; +} + +export class CustomFunctionCompleteFailError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionCompleteFailError; +} diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 69f908c12..6152c3af4 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -299,15 +299,19 @@ export function ignoreSelf(): Middleware<AnyMiddlewareArgs> { const botUserId = args.context.botUserId !== undefined ? (args.context.botUserId as string) : undefined; if (isEventArgs(args)) { - // 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. - if (args.message !== undefined) { - const message = args.message as SlackEventMiddlewareArgs<'message'>['message']; + 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; + if (message.subtype === 'bot_message' && message.bot_id === botId) { + return; + } } } @@ -331,7 +335,7 @@ export function ignoreSelf(): Middleware<AnyMiddlewareArgs> { */ export function subtype(subtype1: string): Middleware<SlackEventMiddlewareArgs<'message'>> { return async ({ message, next }) => { - if (message.subtype === subtype1) { + if (message && message.subtype === subtype1) { await next(); } }; @@ -354,7 +358,7 @@ export function directMention(): Middleware<SlackEventMiddlewareArgs<'message'>> ); } - if (!('text' in message) || message.text === undefined) { + if (!message || !('text' in message) || message.text === undefined) { return; } @@ -406,6 +410,8 @@ function isViewBody( return (body as SlackViewAction).view !== undefined; } -function isEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewareArgs { +function isEventArgs( + args: AnyMiddlewareArgs, +): args is SlackEventMiddlewareArgs { return (args as SlackEventMiddlewareArgs).event !== undefined; } diff --git a/src/middleware/process.ts b/src/middleware/process.ts index b183f46b6..cc963d8f3 100644 --- a/src/middleware/process.ts +++ b/src/middleware/process.ts @@ -21,8 +21,8 @@ export default async function processMiddleware( if (toCallMiddlewareIndex < middleware.length) { lastCalledMiddlewareIndex = toCallMiddlewareIndex; return middleware[toCallMiddlewareIndex]({ - next: () => invokeMiddleware(toCallMiddlewareIndex + 1), ...initialArgs, + next: () => invokeMiddleware(toCallMiddlewareIndex + 1), context, client, logger, diff --git a/src/types/actions/index.ts b/src/types/actions/index.ts index c94a8673c..5c7e7841a 100644 --- a/src/types/actions/index.ts +++ b/src/types/actions/index.ts @@ -3,6 +3,8 @@ import { InteractiveMessage } from './interactive-message'; import { WorkflowStepEdit } from './workflow-step-edit'; import { DialogSubmitAction, DialogValidation } from './dialog-action'; import { SayFn, SayArguments, RespondFn, AckFn } from '../utilities'; +import { FunctionCompleteFn, FunctionFailFn } from '../../CustomFunction'; +import { FunctionInputs } from '../events'; export * from './block-action'; export * from './interactive-message'; @@ -42,9 +44,12 @@ export interface SlackActionMiddlewareArgs<Action extends SlackAction = SlackAct action: this['payload']; body: Action; // all action types except dialog submission have a channel context - say: Action extends Exclude<SlackAction, DialogSubmitAction | WorkflowStepEdit> ? SayFn : never; + say: Action extends Exclude<SlackAction, DialogSubmitAction | WorkflowStepEdit> ? SayFn : undefined; respond: RespondFn; ack: ActionAckFn<Action>; + complete?: FunctionCompleteFn; + fail?: FunctionFailFn; + inputs?: FunctionInputs; } /** diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index 32d9ae528..9abc99558 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -432,11 +432,11 @@ export interface FileUnsharedEvent { } export interface FunctionParams { - type?: string; - name?: string; + type: string; + name: string; description?: string; title?: string; - is_required?: boolean; + is_required: boolean; } export interface FunctionInputs { @@ -451,7 +451,7 @@ export interface FunctionExecutedEvent { id: string; callback_id: string; title: string; - description: string; + description?: string; type: string; input_parameters: FunctionParams[]; output_parameters: FunctionParams[]; diff --git a/src/types/events/index.ts b/src/types/events/index.ts index b888b4a00..0b2c0ef47 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -29,11 +29,11 @@ export { export interface SlackEventMiddlewareArgs<EventType extends string = string> { payload: EventFromType<EventType>; event: this['payload']; - message: EventType extends 'message' ? this['payload'] : never; + message: EventType extends 'message' ? this['payload'] : undefined; body: EnvelopedEvent<this['payload']>; say: WhenEventHasChannelContext<this['payload'], SayFn>; // Add `ack` as undefined for global middleware in TypeScript - ack: undefined; + ack?: undefined; } /** @@ -78,8 +78,8 @@ export type KnownEventFromType<T extends string> = Extract<SlackEvent, { type: T /** * 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 `never`. + * occurred, and returns `Type` when the test passes. Otherwise this returns `undefined`. */ type WhenEventHasChannelContext<Event, Type> = Event extends { channel: string } | { item: { channel: string } } ? Type - : never; + : undefined; diff --git a/src/types/helpers.ts b/src/types/helpers.ts index 3a7a8bb9d..124f8423b 100644 --- a/src/types/helpers.ts +++ b/src/types/helpers.ts @@ -1,9 +1,11 @@ /* 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<string, any>; +// TODO: breaking change: no longer used! remove /** * @deprecated No longer works in TypeScript 4.3 */ diff --git a/src/types/middleware.ts b/src/types/middleware.ts index a36fddc62..d1d8878df 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -1,7 +1,7 @@ import { WebClient } from '@slack/web-api'; import { Logger } from '@slack/logger'; import { StringIndexed } from './helpers'; -import { SlackEventMiddlewareArgs } from './events'; +import { FunctionInputs, SlackEventMiddlewareArgs } from './events'; import { SlackActionMiddlewareArgs } from './actions'; import { SlackCommandMiddlewareArgs } from './command'; import { SlackOptionsMiddlewareArgs } from './options'; @@ -73,6 +73,23 @@ export interface Context extends StringIndexed { */ isEnterpriseInstall: boolean, + /** + * A JIT and function-specific token that, when used to make API calls, + * creates an association between a function's execution and subsequent actions + * (e.g., buttons and other interactivity) + */ + functionBotAccessToken?: string; + + /** + * Function execution ID associated with the event + */ + functionExecutionId?: string; + + /** + * Inputs that were provided to a function when it was executed + */ + functionInputs?: FunctionInputs; + /** * Retry count of an Events API request (this property does not exist for other requests) */ @@ -90,6 +107,9 @@ export const contextBuiltinKeys: string[] = [ 'botUserId', 'teamId', 'enterpriseId', + 'functionBotAccessToken', + 'functionExecutionId', + 'functionInputs', 'retryNum', 'retryReason', ]; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 776c2cfb7..d554c4b36 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -14,6 +14,7 @@ ".eslintrc.js", "docs/**/*", "examples/**/*", + "types-tests/**/*" ], "exclude": [ // Overwrite exclude from the base config to clear the value @@ -21,6 +22,5 @@ // 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 - "types-tests/**/*" ] } diff --git a/types-tests/event.test-d.ts b/types-tests/event.test-d.ts index b24a6687a..06a27ab8f 100644 --- a/types-tests/event.test-d.ts +++ b/types-tests/event.test-d.ts @@ -8,7 +8,7 @@ expectType<void>( expectType<AppMentionEvent>(event); expectNotType<SlackEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -16,7 +16,7 @@ expectType<void>( expectType<ReactionAddedEvent>(event); expectNotType<SlackEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -24,7 +24,7 @@ expectType<void>( expectType<ReactionRemovedEvent>(event); expectNotType<SlackEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -32,7 +32,7 @@ expectType<void>( expectType<UserHuddleChangedEvent>(event); expectNotType<SlackEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -40,7 +40,7 @@ expectType<void>( expectType<UserProfileChangedEvent>(event); expectNotType<SlackEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -48,7 +48,7 @@ expectType<void>( expectType<UserStatusChangedEvent>(event); expectNotType<SlackEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -56,7 +56,7 @@ expectType<void>( expectType<SayFn>(say); expectType<PinAddedEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( @@ -64,19 +64,19 @@ expectType<void>( expectType<SayFn>(say); expectType<PinRemovedEvent>(event); await Promise.resolve(event); - }) + }), ); expectType<void>( app.event('reaction_added', async ({ say, event }) => { expectType<SayFn>(say); await Promise.resolve(event); - }) + }), ); expectType<void>( app.event('reaction_removed', async ({ say, event }) => { expectType<SayFn>(say); await Promise.resolve(event); - }) + }), ); From 23ef516e96ec32fbb85c904e8001e34a5b8ab6ad Mon Sep 17 00:00:00 2001 From: Alissa Renz <alissa.renz@gmail.com> Date: Wed, 14 Aug 2024 12:02:02 +0000 Subject: [PATCH 12/13] [Bug] Fix chained function actions (#2200) --- src/App.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/App.ts b/src/App.ts index ac4cbefd7..184063f81 100644 --- a/src/App.ts +++ b/src/App.ts @@ -976,6 +976,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> if (functionInputs) { context.functionInputs = functionInputs; } } + // Attach and make available the JIT/function-related token on context if (this.attachFunctionToken) { if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } } @@ -1091,7 +1092,11 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed> // Get the client arg let { client } = this; - const token = selectToken(context); + + // If functionBotAccessToken exists on context, the incoming event is function-related *and* the + // user has `attachFunctionToken` enabled. In that case, subsequent calls with the client should + // use the function-related/JIT token in lieu of the botToken or userToken. + const token = context.functionBotAccessToken ? context.functionBotAccessToken : selectToken(context); // Add complete() and fail() utilities for function-related interactivity if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) { From 90ce8120d98267b6a2c11f9152ab549d194d9cda Mon Sep 17 00:00:00 2001 From: Fil Maj <maj.fil@gmail.com> Date: Wed, 14 Aug 2024 16:00:57 +0000 Subject: [PATCH 13/13] Remote functions: typescript integration test with bolt-ts-starter-template (#2202) --- .github/workflows/samples.yml | 41 +++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/samples.yml diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml new file mode 100644 index 000000000..9d0a0f2ba --- /dev/null +++ b/.github/workflows/samples.yml @@ -0,0 +1,41 @@ +# This workflow runs a TypeScript compilation against slack sample apps built on top of bolt-js +name: Samples Integration Type-checking + +on: + push: + branches: [main] + pull_request: + +jobs: + samples: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + sample: + - slack-samples/bolt-ts-starter-template + + steps: + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Checkout bolt-js + uses: actions/checkout@v4 + with: + path: ./bolt-js + - name: Install and link bolt-js + working-directory: ./bolt-js + run: npm i && npm link . + - name: Checkout ${{ matrix.sample }} + uses: actions/checkout@v4 + with: + repository: ${{ matrix.sample }} + path: ./sample + - name: Install sample dependencies and link bolt-js + working-directory: ./sample + run: npm i && npm link @slack/bolt + - name: Compile sample + working-directory: ./sample + run: npx tsc +