From 3c6492e5b50d3c19a761dfdb33aff400a96d9808 Mon Sep 17 00:00:00 2001 From: Alissa Renz Date: Wed, 5 Jun 2024 19:45:29 -0700 Subject: [PATCH 1/9] Fixes and polish for stable release --- src/App.ts | 16 ++++++++++++---- src/CustomFunction.spec.ts | 3 ++- src/CustomFunction.ts | 16 +++++++++++++--- src/errors.ts | 10 ++++++++++ src/types/middleware.ts | 23 ++++++++++++++++++++++- 5 files changed, 59 insertions(+), 9 deletions(-) diff --git a/src/App.ts b/src/App.ts index 9902de838..da0e490e8 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'; @@ -964,9 +965,12 @@ export default class App 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 +1033,7 @@ export default class App ack?: AckFn; complete?: FunctionCompleteFn; fail?: FunctionFailFn; + inputs?: FunctionInputs; } = { body: bodyArg, payload, @@ -1088,6 +1093,7 @@ export default class App 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 +1605,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 +1617,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..3668e19bc 100644 --- a/src/CustomFunction.spec.ts +++ b/src/CustomFunction.spec.ts @@ -65,7 +65,7 @@ describe('CustomFunction class', () => { assert(fakeNext.called); }); - it('should call next if not a workflow step event', async () => { + 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 @@ -215,6 +215,7 @@ function createFakeFunctionExecutedEvent() { }, context: { functionBotAccessToken: 'xwfp-123', + functionExecutionId: 'test_executed_callback_id', }, }; } diff --git a/src/CustomFunction.ts b/src/CustomFunction.ts index 8f52c7e97..50ee1e2d2 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -13,7 +13,7 @@ import { FunctionExecutedEvent, } from './types'; import processMiddleware from './middleware/process'; -import { CustomFunctionInitializationError } from './errors'; +import { CustomFunctionCompleteFailError, CustomFunctionCompleteSuccessError, CustomFunctionInitializationError } from './errors'; /** Interfaces */ @@ -105,6 +105,11 @@ export class CustomFunction { const token = selectToken(context); const { functionExecutionId } = context; + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteSuccessError(errorMsg); + } + return (params: Parameters[0] = {}) => client.functions.completeSuccess({ token, outputs: params.outputs || {}, @@ -123,6 +128,11 @@ export class CustomFunction { const { error } = params ?? {}; const { functionExecutionId } = context; + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteFailError(errorMsg); + } + return client.functions.completeError({ token, error, @@ -158,8 +168,8 @@ 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 + * @param args function_executed event */ export async function processFunctionMiddleware( args: AllCustomFunctionMiddlewareArgs, diff --git a/src/errors.ts b/src/errors.ts index 19e54e39a..b37ba93f4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -36,6 +36,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 { @@ -144,3 +146,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/types/middleware.ts b/src/types/middleware.ts index a36fddc62..531e2142a 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'; @@ -68,6 +68,24 @@ export interface Context extends StringIndexed { * Enterprise Grid Organization ID. */ enterpriseId?: string; + + /** + * 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; + /** * Is the app installed at an Enterprise level? */ @@ -90,6 +108,9 @@ export const contextBuiltinKeys: string[] = [ 'botUserId', 'teamId', 'enterpriseId', + 'functionBotAccessToken', + 'functionExecutionId', + 'functionInputs', 'retryNum', 'retryReason', ]; From 26f12564a1aeba0fafbd22fe6b180a071663e3bb Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Wed, 19 Jun 2024 09:55:50 -0700 Subject: [PATCH 2/9] test(function): confirm correct handler handling * ensure custom function middleware uses the configured token * ensure action handlers have middleware for function executions * ensure listener middleware is honored in function handlers * ensure custom function middleware arguments call correct apis * ensure custom function handlers are setup with valid arguments * document usage of attributes and handlers inline with jsdoc * refactor logic and types into custom function specific files --- src/App-custom-function.spec.ts | 277 +++++++++++++++++++++++++ src/App.ts | 84 ++++---- src/CustomFunction.spec.ts | 238 +++------------------- src/CustomFunction.ts | 351 ++++++++++++++++---------------- src/errors.ts | 11 +- src/middleware/process.ts | 2 +- src/types/events/base-events.ts | 19 +- src/types/functions/index.ts | 154 ++++++++++++++ src/types/index.ts | 7 +- src/types/middleware.ts | 34 +--- 10 files changed, 693 insertions(+), 484 deletions(-) create mode 100644 src/App-custom-function.spec.ts create mode 100644 src/types/functions/index.ts diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts new file mode 100644 index 000000000..28f3c6335 --- /dev/null +++ b/src/App-custom-function.spec.ts @@ -0,0 +1,277 @@ +import 'mocha'; +import { assert } from 'chai'; +import rewiremock from 'rewiremock'; +import sinon, { SinonSpy } from 'sinon'; +import { FunctionsCompleteErrorResponse, FunctionsCompleteSuccessResponse, WebClientOptions } from '@slack/web-api'; +import App from './App'; +import { Override, mergeOverrides } from './test-helpers'; +import { FunctionInputs, Receiver, ReceiverEvent } from './types'; + +class FakeReceiver implements Receiver { + private bolt: App | undefined; + + public init = (bolt: App) => { + this.bolt = bolt; + }; + + public start = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); + + public stop = sinon.fake((...params: any[]): Promise => Promise.resolve([...params])); + + public async sendEvent(event: ReceiverEvent): Promise { + return this.bolt?.processEvent(event); + } +} + +const MOCK_BLOCK_ACTION_ID = 'block_action_id'; +const MOCK_BOT_TOKEN = 'xoxb-example-001'; +const MOCK_BOT_ID = 'B0123456789'; +const MOCK_FUNCTION_BOT_ACCESS_TOKEN = 'xwfp-example-001'; +const MOCK_FUNCTION_CALLBACK_ID = 'mock_function_callback_id'; +const MOCK_FUNCTION_EXECUTION_ID = 'Ft0123456789'; +const MOCK_FUNCTION_INPUT: FunctionInputs = { message: 'hello world' }; +const MOCK_TEAM_ID = 'T0123456789'; +const MOCK_USER_ID = 'U0123456789'; + +describe('App CustomFunction middleware', () => { + const fakeReceiver: FakeReceiver = new FakeReceiver(); + const dummyAuthorizationResult = { + botToken: MOCK_BOT_TOKEN, + botId: MOCK_BOT_ID, + teamId: MOCK_TEAM_ID, + }; + + let MockApp: typeof App; + let fakeFunctionsSuccess: SinonSpy; + let fakeFunctionsError: SinonSpy; + + beforeEach(async () => { + fakeFunctionsSuccess = sinon.fake.resolves({ ok: true }); + fakeFunctionsError = sinon.fake.resolves({ ok: true }); + + const overrides = mergeOverrides(...[ + withFunctionsComplete(fakeFunctionsSuccess, fakeFunctionsError), + withNoopAppMetadata(), + ]); + MockApp = await importApp(overrides); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('completes a function with success using values from the execution event', async () => { + const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); + let response: FunctionsCompleteSuccessResponse | undefined; + + const app = new MockApp({ + authorize: sinon.fake.resolves(dummyAuthorizationResult), + receiver: fakeReceiver, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, inputs }) => { + response = await complete({ outputs: inputs }); + assert(response?.ok); + assert.equal(client.token, MOCK_FUNCTION_BOT_ACCESS_TOKEN); + }); + + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.notCalled); + await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.calledOnce); + assert(fakeFunctionsSuccess.calledWith({ + token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, + function_execution_id: MOCK_FUNCTION_EXECUTION_ID, + outputs: MOCK_FUNCTION_INPUT, + })); + }); + + it('completes a function with error after middleware using application settings', async () => { + const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); + let response: FunctionsCompleteErrorResponse | undefined; + + const app = new MockApp({ + authorize: sinon.fake.resolves(dummyAuthorizationResult), + receiver: fakeReceiver, + attachFunctionToken: false, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, + async ({ context, next }) => { + context.example = '12'; + await next(); + }, + async ({ client, context, fail }) => { + response = await fail({ error: context.example }); + assert(response?.ok); + assert.equal(client.token, MOCK_BOT_TOKEN); + }); + + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.notCalled); + await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); + assert(fakeFunctionsSuccess.notCalled); + assert(fakeFunctionsError.calledOnce); + assert(fakeFunctionsError.calledWith({ + token: MOCK_BOT_TOKEN, + function_execution_id: MOCK_FUNCTION_EXECUTION_ID, + error: '12', + })); + }); + + it('skips function handlers without a matching function callback id', async () => { + const dummyFunctionExecutionEvent = createFunctionExecutionEvent({ + mockFunctionCallbackId: 'unexpected_callback_id', + }); + + const app = new MockApp({ + authorize: sinon.fake.resolves(dummyAuthorizationResult), + receiver: fakeReceiver, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { + assert(false); + }); + + await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); + }); + + it('extracts function execution context for use in block action events', async () => { + const dummyBlockActionEvent = createBlockActionEvent(); + let response: FunctionsCompleteSuccessResponse | undefined; + + const app = new MockApp({ + authorize: sinon.fake.resolves(dummyAuthorizationResult), + receiver: fakeReceiver, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { + assert(false); + }); + + app.action(MOCK_BLOCK_ACTION_ID, async (args) => { + const { context, complete } = args as any; + response = await complete(); + assert(response?.ok); + assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); + assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); + assert.deepEqual(context.functionInputs, MOCK_FUNCTION_INPUT); + }); + + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.notCalled); + await fakeReceiver.sendEvent(dummyBlockActionEvent); + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.called); + assert(fakeFunctionsSuccess.calledWith({ + token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, + function_execution_id: MOCK_FUNCTION_EXECUTION_ID, + outputs: {}, + })); + }); +}); + +/** + * Generators for mock function events are found below + */ + +interface MockFunctionContextOverrides { + mockFunctionBotAccessToken?: string, + mockFunctionCallbackId?: string, + mockFunctionExecutionId?: string, + mockFunctionInput?: FunctionInputs, +} + +function createFunctionExecutionEvent(overrides?: MockFunctionContextOverrides): ReceiverEvent { + const defaults = { + mockFunctionBotAccessToken: MOCK_FUNCTION_BOT_ACCESS_TOKEN, + mockFunctionCallbackId: MOCK_FUNCTION_CALLBACK_ID, + mockFunctionExecutionId: MOCK_FUNCTION_EXECUTION_ID, + mockFunctionInput: MOCK_FUNCTION_INPUT, + }; + const values = Object.assign(defaults, overrides); + return { + ack: () => Promise.resolve(undefined), + body: { + event: { + type: 'function_executed', + bot_access_token: values.mockFunctionBotAccessToken, + function: { + callback_id: values.mockFunctionCallbackId, + }, + function_execution_id: values.mockFunctionExecutionId, + inputs: { + message: values.mockFunctionInput.message, + }, + }, + }, + }; +} + +function createBlockActionEvent(overrides?: MockFunctionContextOverrides): ReceiverEvent { + const defaults = { + mockFunctionBotAccessToken: MOCK_FUNCTION_BOT_ACCESS_TOKEN, + mockFunctionCallbackId: MOCK_FUNCTION_CALLBACK_ID, + mockFunctionExecutionId: MOCK_FUNCTION_EXECUTION_ID, + mockFunctionInput: MOCK_FUNCTION_INPUT, + }; + const values = Object.assign(defaults, overrides); + return { + ack: () => Promise.resolve(undefined), + body: { + type: 'block_actions', + team: { id: MOCK_TEAM_ID }, + user: { id: MOCK_USER_ID }, + actions: [ + { + action_id: MOCK_BLOCK_ACTION_ID, + }, + ], + bot_access_token: values.mockFunctionBotAccessToken, + function_data: { + execution_id: values.mockFunctionExecutionId, + inputs: { + message: values.mockFunctionInput.message, + }, + }, + }, + }; +} + +/** + * Overrides for spying on mocks are below this comment + */ + +async function importApp( + overrides: Override = mergeOverrides(withNoopAppMetadata()), +): Promise { + return (await rewiremock.module(() => import('./App'), overrides)).default; +} + +function withFunctionsComplete(spySuccess: SinonSpy, spyError: SinonSpy): Override { + return { + '@slack/web-api': { + WebClient: class { + public readonly token?: string; + + public constructor(token: string, _options?: WebClientOptions) { + this.token = token; + } + + public functions = { + completeSuccess: spySuccess, + completeError: spyError, + }; + }, + }, + }; +} + +function withNoopAppMetadata(): Override { + return { + '@slack/web-api': { + addAppMetadata: sinon.fake(), + }, + }; +} diff --git a/src/App.ts b/src/App.ts index da0e490e8..c43206d55 100644 --- a/src/App.ts +++ b/src/App.ts @@ -54,6 +54,9 @@ import { SlashCommand, WorkflowStepEdit, SlackOptions, + CustomFunctionMiddleware, + FunctionCompleteFn, + FunctionFailFn, FunctionInputs, } from './types'; import { IncomingEventType, getTypeAndConversation, assertNever, isBodyWithTypeEnterpriseInstall, isEventTypeToSkipAuthorize } from './helpers'; @@ -62,7 +65,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, CustomFunction, CustomFunctionMiddleware } from './CustomFunction'; +import CustomFunction 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 @@ -110,6 +113,12 @@ export interface AppOptions { tokenVerificationEnabled?: boolean; deferInitialization?: boolean; extendedErrorHandler?: boolean; + /** + * Option to decides if `App#function()` handlers use a just-in-time (JIT) + * `token` created within the context of the executing function or if the the + * bot or user `token` should be used. Defaults to `true` for the JIT token. + * @link https://api.slack.com/authentication/token-types#wfb + */ attachFunctionToken?: boolean; } @@ -527,10 +536,26 @@ export default class App } /** - * Register CustomFunction middleware - */ + * Custom function listener and middleware that executes for a matching function callbackId. + * @param callbackId a unique string identifier representing the function. + * @param listeners middleware that processes matching function exections. + * @example + * app.function('reverse_string', ({ inputs, complete, fail }) => { + * if (inputs.original.length <= 0) { + * await fail({ error: 'No string was provided!' }); + * return; + * } + * await complete({ + * outputs: { + * reversed: inputs.original.split('').reverse().join(''), + * }, + * }); + * return; + * }); + * @link https://api.slack.com/automation/functions/custom + */ public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this { - const fn = new CustomFunction(callbackId, listeners); + const fn = new CustomFunction(callbackId, listeners, this.client); const m = fn.getMiddleware(); this.middleware.push(m); return this; @@ -963,19 +988,9 @@ export default class App isEnterpriseInstall, retryNum: event.retryNum, retryReason: event.retryReason, + ...CustomFunction.extractContext(body, this.attachFunctionToken), }; - // 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; } - } - // Factory for say() utility const createSay = (channelId: string): SayFn => { const token = selectToken(context); @@ -1085,17 +1100,16 @@ export default class App await ack(); } + // Set custom function complete(), fail(), and inputs argument for action listeners + if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) { + const middlewareArgs = CustomFunction.middlewareArgs(context, this.client); + Object.assign(listenerArgs, middlewareArgs); + } + // Get the client arg let { client } = this; const token = selectToken(context); - // Add complete() and fail() utilities for function-related interactivity - 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) { let pool; const clientOptionsCopy = { ...this.clientOptions }; @@ -1576,8 +1590,11 @@ function isBlockActionOrInteractiveMessageBody( return (body as SlackActionMiddlewareArgs['body']).actions !== undefined; } -// Returns either a bot token or a user token for client, say() +// Returns a context token depending on app settings and presence for client, say() function selectToken(context: Context): string | undefined { + if (context.functionBotAccessToken) { + return context.functionBotAccessToken; + } return context.botToken !== undefined ? context.botToken : context.userToken; } @@ -1602,27 +1619,6 @@ function escapeHtml(input: string | undefined | null): string { return ''; } -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) { - functionExecutionId = body.event.function_execution_id; - functionBotAccessToken = body.event.bot_access_token; - } - - // interactivity (block_actions) - if (body.function_data) { - functionExecutionId = body.function_data.execution_id; - functionBotAccessToken = body.bot_access_token; - functionInputs = body.function_data.inputs; - } - - return { functionExecutionId, functionBotAccessToken, functionInputs }; -} - // ---------------------------- // Instrumentation // Don't change the position of the following code diff --git a/src/CustomFunction.spec.ts b/src/CustomFunction.spec.ts index 3668e19bc..c9010cdb4 100644 --- a/src/CustomFunction.spec.ts +++ b/src/CustomFunction.spec.ts @@ -1,238 +1,54 @@ 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 { - return rewiremock.module(() => import('./CustomFunction'), overrides); -} +import { WebClient } from '@slack/web-api'; -const MOCK_FN = async () => {}; -const MOCK_FN_2 = async () => {}; +import CustomFunction from './CustomFunction'; +import { CustomFunctionInitializationError, CustomFunctionRuntimeError } from './errors'; +import { Context, CustomFunctionMiddleware } from './types'; -const MOCK_MIDDLEWARE_SINGLE = [MOCK_FN]; -const MOCK_MIDDLEWARE_MULTIPLE = [MOCK_FN, MOCK_FN_2]; +/** + * Common implementation checks for App#function are found in App-custom-function.spec.ts + * while some of the edge and error cases are covered here. + */ 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); - }); + const MOCK_FN = async () => { }; + let mockClient: WebClient; - it('should accept multiple functions as middleware', async () => { - const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE); - assert.isNotNull(fn); - }); + beforeEach(() => { + mockClient = new WebClient(); }); - 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 function executed event', async () => { - const fn = new CustomFunction('test_view_callback_id', MOCK_MIDDLEWARE_SINGLE); - const middleware = fn.getMiddleware(); - const fakeViewArgs = createFakeViewEvent() as unknown as - SlackCustomFunctionMiddlewareArgs & AllMiddlewareArgs; - - const fakeNext = sinon.spy(); - fakeViewArgs.next = fakeNext; - - await middleware(fakeViewArgs); - - assert(fakeNext.called); - }); - }); - - describe('validate', () => { + describe('constructor', () => { 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 fn = () => new CustomFunction(badId, [MOCK_FN], mockClient); const expectedMsg = 'CustomFunction expects a callback_id as the first argument'; - assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + assert.throws(fn, 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 fn = () => new CustomFunction('callback_id', badConfig, mockClient); const expectedMsg = 'CustomFunction expects a function or array of functions as the second argument'; - assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + assert.throws(fn, 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); + const badMiddleware = [async () => { }, 'not-a-function'] as unknown as CustomFunctionMiddleware; + const fn = () => new CustomFunction('callback_id', badMiddleware, mockClient); + const expectedMsg = 'CustomFunction middleware argument 1 is not a function but should be a function'; + assert.throws(fn, CustomFunctionInitializationError, expectedMsg); }); }); - 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); - const fn2 = sinon.spy(async () => {}); - const fakeMiddleware = [fn1, fn2] as CustomFunctionMiddleware; - - await processFunctionMiddleware(fakeArgs, fakeMiddleware); - - assert(fn1.called); - assert(fn2.called); + describe('middlewareArgs', () => { + it('should throw an error if no function execution id exists in the context', async () => { + const mockContext: Context = { isEnterpriseInstall: true }; + const fn = () => CustomFunction.middlewareArgs(mockContext, mockClient); + const expectedMsg = 'No function_execution_id was found in the context'; + assert.throws(fn, CustomFunctionRuntimeError, expectedMsg); }); }); }); - -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', - functionExecutionId: 'test_executed_callback_id', - }, - }; -} - -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 50ee1e2d2..744c06d34 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -1,221 +1,222 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { - WebClient, FunctionsCompleteErrorResponse, FunctionsCompleteSuccessResponse, + WebClient, } from '@slack/web-api'; import { - Middleware, - AllMiddlewareArgs, + CustomFunctionInitializationError, + CustomFunctionRuntimeError, +} from './errors'; +import { + AllCustomFunctionMiddlewareArgs, AnyMiddlewareArgs, - SlackEventMiddlewareArgs, Context, - FunctionExecutedEvent, + CustomFunctionContext, + CustomFunctionMiddleware, + CustomFunctionMiddlewareArgs, + FunctionCompleteArguments, + FunctionFailArguments, + Middleware, } from './types'; import processMiddleware from './middleware/process'; -import { CustomFunctionCompleteFailError, CustomFunctionCompleteSuccessError, CustomFunctionInitializationError } from './errors'; - -/** Interfaces */ - -interface FunctionCompleteArguments { - outputs?: { - [key: string]: any; - }; -} - -export interface FunctionCompleteFn { - (params?: FunctionCompleteArguments): Promise; -} - -interface FunctionFailArguments { - error: string; -} - -export interface FunctionFailFn { - (params: FunctionFailArguments): Promise; -} - -export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { - inputs: FunctionExecutedEvent['inputs']; - complete: FunctionCompleteFn; - fail: FunctionFailFn; -} - -/** Types */ - -export type SlackCustomFunctionMiddlewareArgs = CustomFunctionExecuteMiddlewareArgs; - -type CustomFunctionExecuteMiddleware = Middleware[]; - -export type CustomFunctionMiddleware = Middleware[]; - -export type AllCustomFunctionMiddlewareArgs - = T & AllMiddlewareArgs; - -/** Constants */ +import { StringIndexed } from './types/helpers'; +/** Payload event types related to custom functions. */ const VALID_PAYLOAD_TYPES = new Set(['function_executed']); -/** Class */ - -export class CustomFunction { +export default class CustomFunction { /** Function callback_id */ public callbackId: string; private middleware: CustomFunctionMiddleware; + private client: WebClient; + + /** + * Builds a custom function listener for the callback_id function. + * @param callbackId - the function callback ID. + * @param middleware - an array of function middleware. + * @param client - custom configurations for a web client. + */ public constructor( callbackId: string, - middleware: CustomFunctionExecuteMiddleware, + middleware: CustomFunctionMiddleware, + client: WebClient, ) { - validate(callbackId, middleware); - + CustomFunction.validateCustomFunctionHandler(callbackId, middleware); this.callbackId = callbackId; this.middleware = middleware; + this.client = client; } - public getMiddleware(): Middleware { - return async (args): Promise => { - if (isFunctionEvent(args) && this.matchesConstraints(args)) { - return this.processEvent(args); - } - return args.next(); - }; - } - - private matchesConstraints(args: SlackCustomFunctionMiddlewareArgs): boolean { - return args.payload.function.callback_id === this.callbackId; - } - - private async processEvent(args: AllCustomFunctionMiddlewareArgs): Promise { - const functionArgs = enrichFunctionArgs(args); - const functionMiddleware = this.getFunctionMiddleware(); - return processFunctionMiddleware(functionArgs, functionMiddleware); - } - - private getFunctionMiddleware(): CustomFunctionMiddleware { - return this.middleware; + /** + * Ensure arguments provided to the `function()` handler match expected types. + * A valid callback ID and a middleware function or functions are required. + * @param callbackId - the function callback ID. + * @param middleware - an array of function middleware. + * @throws whenever an input is invalid. + */ + private static validateCustomFunctionHandler(callbackId: string, middleware: CustomFunctionMiddleware): void { + if (typeof callbackId !== 'string') { + const errorMsg = 'CustomFunction expects a callback_id as the first argument'; + throw new CustomFunctionInitializationError(errorMsg); + } + 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); + } + if (Array.isArray(middleware)) { + middleware.forEach((fn, idx) => { + if (typeof fn !== 'function') { + const errorMsg = `CustomFunction middleware argument ${idx} is not a function but should be a function`; + throw new CustomFunctionInitializationError(errorMsg); + } + }); + } } /** - * Factory for `complete()` utility - * @param args function_executed event + * Gather information related to the function and augment event context values + * for function executions. + * @param body - the received event. + * @param withToken - if the function bot access token should be included. + * @returns included context values. */ - public static createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn { - const token = selectToken(context); - const { functionExecutionId } = context; + public static extractContext(body: StringIndexed, withToken: boolean): CustomFunctionContext { + const context: CustomFunctionContext = {}; - if (!functionExecutionId) { - const errorMsg = 'No function_execution_id found'; - throw new CustomFunctionCompleteSuccessError(errorMsg); + // function_executed event + if (body.event && body.event.type === 'function_executed') { + if (body.event.function_execution_id) { + context.functionExecutionId = body.event.function_execution_id; + } + if (body.event.inputs) { + context.functionInputs = body.event.inputs; + } + if (withToken && body.event.bot_access_token) { + context.functionBotAccessToken = body.event.bot_access_token; + } } - return (params: Parameters[0] = {}) => client.functions.completeSuccess({ - token, - outputs: params.outputs || {}, - function_execution_id: functionExecutionId, - }); + // interactivity (block_actions) + if (body.function_data) { + if (body.function_data.execution_id) { + context.functionExecutionId = body.function_data.execution_id; + } + if (body.function_data.inputs) { + context.functionInputs = body.function_data.inputs; + } + if (withToken && body.bot_access_token) { + context.functionBotAccessToken = body.bot_access_token; + } + } + + return context; } /** - * Factory for `fail()` utility - * @param args function_executed event - */ - public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn { - const token = selectToken(context); - - return (params: Parameters[0]) => { - const { error } = params ?? {}; - const { functionExecutionId } = context; - - if (!functionExecutionId) { - const errorMsg = 'No function_execution_id found'; - throw new CustomFunctionCompleteFailError(errorMsg); + * Process the event using function arguments as middleware when matching + * function constraints are found. + * @returns function that is evaluated or skipped with given arguments. + */ + public getMiddleware(): Middleware { + return async (args): Promise => { + if (!CustomFunction.isFunctionEvent(args) || this.callbackId !== args.payload.function.callback_id) { + return args.next(); } - - return client.functions.completeError({ - token, - error, - function_execution_id: functionExecutionId, - }); + const middlewareArgs = CustomFunction.middlewareArgs(args.context, this.client); + Object.assign(args, middlewareArgs); + return CustomFunction.processFunctionMiddleware(args, this.middleware); }; } -} -/** Helper Functions */ -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); + /** + * Determine if the arguments represent a function_executed event. + * @param args - the inputs received as middleware of an event. + * @returns if args is a function_executed event via payload. + */ + private static isFunctionEvent(args: AnyMiddlewareArgs): args is AllCustomFunctionMiddlewareArgs { + return VALID_PAYLOAD_TYPES.has(args.payload.type); } - // 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); + /** + * Gather function callbacks as middleware to invoke each one as part of the + * event lifecycle. + * @param args - contains arguments include as middleware inputs. + * @param middleware - holds a list of middleware to execute. + */ + private static async processFunctionMiddleware( + args: AllCustomFunctionMiddlewareArgs, + middleware: CustomFunctionMiddleware, + ): Promise { + const { context, client, logger } = args; + const callbacks = [...middleware] as Middleware[]; + const lastCallback = callbacks.pop(); + + if (lastCallback !== undefined) { + await processMiddleware( + callbacks, + args, + context, + client, + logger, + async () => lastCallback({ ...args, context, client, logger }), + ); + } } - // 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); - } - }); + /** + * Configure middleware arguments to provide to function listeners. + * @param context - the function execution context. + * @param client - a configured web client. + * @returns listener arguments configured for the function execution. + * @throws when function_execution_id is not included in arguments. + */ + public static middlewareArgs(context: Context, client: WebClient): CustomFunctionMiddlewareArgs { + const { functionExecutionId, functionInputs } = context; + if (functionExecutionId === undefined) { + throw new CustomFunctionRuntimeError('No function_execution_id was found in the context'); + } + const token = CustomFunction.selectToken(context); + return { + inputs: functionInputs ?? {}, + complete: (params: FunctionCompleteArguments = {}): Promise => ( + client.functions.completeSuccess({ + token, + outputs: params.outputs ?? {}, + function_execution_id: functionExecutionId, + }) + ), + fail: (params: FunctionFailArguments): Promise => ( + client.functions.completeError({ + token, + error: params.error, + function_execution_id: functionExecutionId, + }) + ), + }; } -} -/** - * `processFunctionMiddleware()` invokes each listener middleware - * @param args function_executed event - */ -export async function processFunctionMiddleware( - args: AllCustomFunctionMiddlewareArgs, - middleware: CustomFunctionMiddleware, -): Promise { - const { context, client, logger } = args; - const callbacks = [...middleware] as Middleware[]; - const lastCallback = callbacks.pop(); - - if (lastCallback !== undefined) { - await processMiddleware( - callbacks, args, context, client, logger, - async () => lastCallback({ ...args, context, client, logger }), - ); + /** + * Gather a token matching the function context set during app initialization. + * This is either from the function execution context or a bot or user token and + * is the token used in `function()` handlers. + * + * The functionBotAccessToken is set to context during `App` initialization if + * configured during setup so that continuity between a function_executed event + * and subsequent interactive events (actions) is preserved. + * + * The botToken and userToken remain available in context regardless of this but + * the functionBotAccess token cannot be switched between functions. Middleware + * granularity for this decision is left TODO. + * + * @param context - the incoming payload context. + * @link https://github.com/slackapi/bolt-js/pull/2026#discussion_r1467123047 + */ + private static selectToken(context: Context): string | undefined { + if (context.functionBotAccessToken) { + return context.functionBotAccessToken; + } + return context.botToken ?? context.userToken; } } - -export 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.functionBotAccessToken ? context.functionBotAccessToken : context.botToken || context.userToken; -} - -/** - * `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 - * */ -export function enrichFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs { - const { next: _next, ...functionArgs } = args; - 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 }); - enrichedArgs.client = client; - - // Utility args - enrichedArgs.inputs = enrichedArgs.event.inputs; - enrichedArgs.complete = CustomFunction.createFunctionComplete(enrichedArgs.context, client); - enrichedArgs.fail = CustomFunction.createFunctionFail(enrichedArgs.context, client); - - return enrichedArgs; -} diff --git a/src/errors.ts b/src/errors.ts index 09a5a8b7f..fa4f822b4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -41,8 +41,7 @@ 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', + CustomFunctionRuntimeError = 'slack_bolt_custom_function_runtime_error', } export class UnknownError extends Error implements CodedError { @@ -152,10 +151,6 @@ export class CustomFunctionInitializationError extends Error implements CodedErr 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; +export class CustomFunctionRuntimeError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionRuntimeError; } 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/events/base-events.ts b/src/types/events/base-events.ts index 32d9ae528..dc3a9259c 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -1,4 +1,5 @@ import { View, MessageAttachment, KnownBlock, Block } from '@slack/types'; +import { FunctionInputs, FunctionParams } from '../functions'; import { MessageEvent as AllMessageEvents, MessageMetadataEvent as AllMessageMetadataEvents } from './message-events'; /** @@ -431,34 +432,20 @@ export interface FileUnsharedEvent { event_ts: string; } -export interface FunctionParams { - type?: string; - name?: string; - description?: string; - title?: string; - is_required?: boolean; -} - -export interface FunctionInputs { - [key: string]: unknown; -} - -export type FunctionOutputValues = FunctionInputs; - export interface FunctionExecutedEvent { type: 'function_executed'; function: { id: string; callback_id: string; title: string; - description: string; + description?: string; type: string; input_parameters: FunctionParams[]; output_parameters: FunctionParams[]; app_id: string; date_created: number; date_updated: number; - date_deleted: number + date_deleted: number; }; inputs: FunctionInputs; function_execution_id: string; diff --git a/src/types/functions/index.ts b/src/types/functions/index.ts new file mode 100644 index 000000000..a2c877159 --- /dev/null +++ b/src/types/functions/index.ts @@ -0,0 +1,154 @@ +import { + FunctionsCompleteErrorResponse, + FunctionsCompleteSuccessResponse, +} from '@slack/web-api'; +import { + AllMiddlewareArgs, + FunctionExecutedEvent, + Middleware, + SlackEventMiddlewareArgs, +} from '../index'; + +/** + * Manifest values used to describe metadata of the function. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionParams { + type: string; + name: string; + description?: string; + title?: string; + is_required: boolean; +} + +/** + * Mappings of defined inputs to actual input values. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionInputs { + [key: string]: unknown; +} + +/** + * Mappings of provided outputs and resulting values. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionOutputValues { + [key: string]: unknown; +} + +/** + * Output parameters from a successful function execution. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionCompleteArguments { + outputs?: { + [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any + }; +} + +/** + * The function called with output parameters to complete the function execution with success. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionCompleteFn { + (params?: FunctionCompleteArguments): Promise; +} + +/** + * Information about the failed function execution. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionFailArguments { + error: string; +} + +/** + * The function called with error information to end the function executions with an error. + * @link https://api.slack.com/automation/functions/custom + */ +export interface FunctionFailFn { + (params: FunctionFailArguments): Promise; +} + +/** + * Incoming event details specific to the function execution. These are optional + * and not added to context if not found in the event. + */ +export interface CustomFunctionContext { + /** + * The unique id of the function execution event. + * @link https://api.slack.com/automation/functions/custom + */ + functionExecutionId?: string; + /** + * A JIT bot access token specific to the execution. + * @link https://api.slack.com/authentication/token-types#wfb + */ + functionBotAccessToken?: string; + /** + * Input values for the function from the execution. + * @link https://api.slack.com/automation/functions/custom + */ + functionInputs?: FunctionInputs; +} + +/** + * Additional arguments provided to listener handlers and middleware for a function. + * The complete set of arguments is found by extending the function executed event. + * @link https://api.slack.com/automation/functions/custom + */ +export interface CustomFunctionMiddlewareArgs { + /** + * Parameters provided to the function from the function execution event. + * @property an object of manifest defined keys with values from the execution event. + * @example + * const { user_id, message } = inputs; + * @link https://api.slack.com/automation/functions/custom + */ + inputs: FunctionExecutedEvent['inputs']; + + /** + * Complete the function with success and outputs. + * @property parameters returned from the function. + * @example + * const response = await complete({ + * outputs: { + * is_even: true, + * number: 12, + * } + * }); + * @link https://api.slack.com/automation/functions/custom + */ + complete: FunctionCompleteFn; + + /** + * End the function with an error. + * @property details sent to the user about the cause of error. + * @example + * const response = await fail({ + * error: 'Something strange happened!', + * }); + * @link https://api.slack.com/automation/functions/custom + */ + fail: FunctionFailFn; +} + +/** + * Listener used as middleware for a function handler. + * @link https://api.slack.com/automation/functions/custom + */ +export type SlackCustomFunctionMiddlewareArgs = SlackEventMiddlewareArgs<'function_executed'> & CustomFunctionMiddlewareArgs; + +/** + * Multiple listeners that make the function handler middleware. + * @link https://api.slack.com/automation/functions/custom + */ +export type CustomFunctionMiddleware = Middleware[]; + +/** + * Arugments provided to the listner handlers for a function execution event. + * @link https://api.slack.com/automation/functions/custom + */ +export type AllCustomFunctionMiddlewareArgs + = T & AllMiddlewareArgs; diff --git a/src/types/index.ts b/src/types/index.ts index 4d3c2d92b..1fd141b7e 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,9 +1,10 @@ -export * from './utilities'; -export * from './middleware'; export * from './actions'; export * from './command'; export * from './events'; +export * from './functions'; +export * from './middleware'; export * from './options'; -export * from './view'; export * from './receiver'; export * from './shortcuts'; +export * from './utilities'; +export * from './view'; diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 531e2142a..aba3e3c6f 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -1,21 +1,23 @@ import { WebClient } from '@slack/web-api'; import { Logger } from '@slack/logger'; import { StringIndexed } from './helpers'; -import { FunctionInputs, SlackEventMiddlewareArgs } from './events'; import { SlackActionMiddlewareArgs } from './actions'; import { SlackCommandMiddlewareArgs } from './command'; +import { SlackEventMiddlewareArgs } from './events'; +import { CustomFunctionContext, SlackCustomFunctionMiddlewareArgs } from './functions'; import { SlackOptionsMiddlewareArgs } from './options'; import { SlackShortcutMiddlewareArgs } from './shortcuts'; import { SlackViewMiddlewareArgs } from './view'; // TODO: rename this to AnyListenerArgs, and all the constituent types export type AnyMiddlewareArgs = - | SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackCommandMiddlewareArgs + | SlackCustomFunctionMiddlewareArgs + | SlackEventMiddlewareArgs | SlackOptionsMiddlewareArgs - | SlackViewMiddlewareArgs - | SlackShortcutMiddlewareArgs; + | SlackShortcutMiddlewareArgs + | SlackViewMiddlewareArgs; export interface AllMiddlewareArgs { context: Context & CustomContext; @@ -34,7 +36,7 @@ export interface Middleware { * Context object, which provides contextual information associated with an incoming requests. * You can set any other custom attributes in global middleware as long as the key does not conflict with others. */ -export interface Context extends StringIndexed { +export interface Context extends StringIndexed, CustomFunctionContext { /** * A bot token, which starts with `xoxb-`. * This value can be used by `say` (preferred over userToken), @@ -68,24 +70,6 @@ export interface Context extends StringIndexed { * Enterprise Grid Organization ID. */ enterpriseId?: string; - - /** - * 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; - /** * Is the app installed at an Enterprise level? */ @@ -108,11 +92,9 @@ export const contextBuiltinKeys: string[] = [ 'botUserId', 'teamId', 'enterpriseId', - 'functionBotAccessToken', - 'functionExecutionId', - 'functionInputs', 'retryNum', 'retryReason', + ...Array(), ]; export type NextFn = () => Promise; From 2f5da2132cc782b281a49199ebd9b40e146459a4 Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Thu, 20 Jun 2024 09:49:08 -0700 Subject: [PATCH 3/9] test(fix): instantiate a new fake receiver before each test --- src/App-custom-function.spec.ts | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts index 28f3c6335..dbc82a10d 100644 --- a/src/App-custom-function.spec.ts +++ b/src/App-custom-function.spec.ts @@ -33,19 +33,20 @@ const MOCK_FUNCTION_INPUT: FunctionInputs = { message: 'hello world' }; const MOCK_TEAM_ID = 'T0123456789'; const MOCK_USER_ID = 'U0123456789'; -describe('App CustomFunction middleware', () => { - const fakeReceiver: FakeReceiver = new FakeReceiver(); - const dummyAuthorizationResult = { - botToken: MOCK_BOT_TOKEN, - botId: MOCK_BOT_ID, - teamId: MOCK_TEAM_ID, - }; +const MOCK_AUTHORIZATION_RESULT = { + botToken: MOCK_BOT_TOKEN, + botId: MOCK_BOT_ID, + teamId: MOCK_TEAM_ID, +}; +describe('App CustomFunction middleware', () => { + let fakeReceiver: FakeReceiver; let MockApp: typeof App; let fakeFunctionsSuccess: SinonSpy; let fakeFunctionsError: SinonSpy; beforeEach(async () => { + fakeReceiver = new FakeReceiver(); fakeFunctionsSuccess = sinon.fake.resolves({ ok: true }); fakeFunctionsError = sinon.fake.resolves({ ok: true }); @@ -65,7 +66,7 @@ describe('App CustomFunction middleware', () => { let response: FunctionsCompleteSuccessResponse | undefined; const app = new MockApp({ - authorize: sinon.fake.resolves(dummyAuthorizationResult), + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), receiver: fakeReceiver, }); @@ -92,7 +93,7 @@ describe('App CustomFunction middleware', () => { let response: FunctionsCompleteErrorResponse | undefined; const app = new MockApp({ - authorize: sinon.fake.resolves(dummyAuthorizationResult), + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), receiver: fakeReceiver, attachFunctionToken: false, }); @@ -126,7 +127,7 @@ describe('App CustomFunction middleware', () => { }); const app = new MockApp({ - authorize: sinon.fake.resolves(dummyAuthorizationResult), + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), receiver: fakeReceiver, }); @@ -142,7 +143,7 @@ describe('App CustomFunction middleware', () => { let response: FunctionsCompleteSuccessResponse | undefined; const app = new MockApp({ - authorize: sinon.fake.resolves(dummyAuthorizationResult), + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), receiver: fakeReceiver, }); From a818873db9e41dea25736d49765bfa42058b2735 Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Thu, 20 Jun 2024 09:52:45 -0700 Subject: [PATCH 4/9] refactor(test): initialize a constant response for asserts --- src/App-custom-function.spec.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts index dbc82a10d..5c997521f 100644 --- a/src/App-custom-function.spec.ts +++ b/src/App-custom-function.spec.ts @@ -2,7 +2,7 @@ import 'mocha'; import { assert } from 'chai'; import rewiremock from 'rewiremock'; import sinon, { SinonSpy } from 'sinon'; -import { FunctionsCompleteErrorResponse, FunctionsCompleteSuccessResponse, WebClientOptions } from '@slack/web-api'; +import { WebClientOptions } from '@slack/web-api'; import App from './App'; import { Override, mergeOverrides } from './test-helpers'; import { FunctionInputs, Receiver, ReceiverEvent } from './types'; @@ -63,7 +63,6 @@ describe('App CustomFunction middleware', () => { it('completes a function with success using values from the execution event', async () => { const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); - let response: FunctionsCompleteSuccessResponse | undefined; const app = new MockApp({ authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), @@ -71,7 +70,7 @@ describe('App CustomFunction middleware', () => { }); app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, inputs }) => { - response = await complete({ outputs: inputs }); + const response = await complete({ outputs: inputs }); assert(response?.ok); assert.equal(client.token, MOCK_FUNCTION_BOT_ACCESS_TOKEN); }); @@ -90,7 +89,6 @@ describe('App CustomFunction middleware', () => { it('completes a function with error after middleware using application settings', async () => { const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); - let response: FunctionsCompleteErrorResponse | undefined; const app = new MockApp({ authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), @@ -104,7 +102,7 @@ describe('App CustomFunction middleware', () => { await next(); }, async ({ client, context, fail }) => { - response = await fail({ error: context.example }); + const response = await fail({ error: context.example }); assert(response?.ok); assert.equal(client.token, MOCK_BOT_TOKEN); }); @@ -140,7 +138,6 @@ describe('App CustomFunction middleware', () => { it('extracts function execution context for use in block action events', async () => { const dummyBlockActionEvent = createBlockActionEvent(); - let response: FunctionsCompleteSuccessResponse | undefined; const app = new MockApp({ authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), @@ -153,7 +150,7 @@ describe('App CustomFunction middleware', () => { app.action(MOCK_BLOCK_ACTION_ID, async (args) => { const { context, complete } = args as any; - response = await complete(); + const response = await complete(); assert(response?.ok); assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); From d30153f31f54016f6d69575767c8f60670d71fd0 Mon Sep 17 00:00:00 2001 From: Eden Zimbelman Date: Thu, 20 Jun 2024 09:53:57 -0700 Subject: [PATCH 5/9] test(fix): assert failure with builtin asserts for failure cases Co-authored-by: William Bergamin --- src/App-custom-function.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts index 5c997521f..51fda9061 100644 --- a/src/App-custom-function.spec.ts +++ b/src/App-custom-function.spec.ts @@ -130,7 +130,7 @@ describe('App CustomFunction middleware', () => { }); app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { - assert(false); + assert.fail('function handler for "${MOCK_FUNCTION_CALLBACK_ID}" should not execute'); }); await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); From 6eb5fcc5d0cb4e273315f7e84984c3f0bb248679 Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Thu, 20 Jun 2024 10:00:48 -0700 Subject: [PATCH 6/9] test(fix): quote templated strings with backtick for escape --- src/App-custom-function.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts index 51fda9061..4a5b10673 100644 --- a/src/App-custom-function.spec.ts +++ b/src/App-custom-function.spec.ts @@ -130,7 +130,7 @@ describe('App CustomFunction middleware', () => { }); app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { - assert.fail('function handler for "${MOCK_FUNCTION_CALLBACK_ID}" should not execute'); + assert.fail(`function handler for "${MOCK_FUNCTION_CALLBACK_ID}" was executed`); }); await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); From 8a85665dcca046c32df4617d4a18d20776ef2b42 Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Thu, 20 Jun 2024 10:07:07 -0700 Subject: [PATCH 7/9] test: confirm a fail argument is provided to action handlers --- src/App-custom-function.spec.ts | 37 +++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts index 4a5b10673..77d426e0e 100644 --- a/src/App-custom-function.spec.ts +++ b/src/App-custom-function.spec.ts @@ -136,7 +136,7 @@ describe('App CustomFunction middleware', () => { await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); }); - it('extracts function execution context for use in block action events', async () => { + it('extracts function execution context for success in block actions', async () => { const dummyBlockActionEvent = createBlockActionEvent(); const app = new MockApp({ @@ -145,7 +145,7 @@ describe('App CustomFunction middleware', () => { }); app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { - assert(false); + assert.fail(`function handler for "${MOCK_FUNCTION_CALLBACK_ID}" was executed`); }); app.action(MOCK_BLOCK_ACTION_ID, async (args) => { @@ -168,6 +168,39 @@ describe('App CustomFunction middleware', () => { outputs: {}, })); }); + + it('extracts function execution context for failure in block actions', async () => { + const dummyBlockActionEvent = createBlockActionEvent(); + + const app = new MockApp({ + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), + receiver: fakeReceiver, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, async () => { + assert.fail(`function handler for "${MOCK_FUNCTION_CALLBACK_ID}" was executed`); + }); + + app.action(MOCK_BLOCK_ACTION_ID, async (args) => { + const { context, fail, inputs } = args as any; + const response = await fail({ error: inputs.message }); + assert(response?.ok); + assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); + assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); + assert.deepEqual(context.functionInputs, MOCK_FUNCTION_INPUT); + }); + + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.notCalled); + await fakeReceiver.sendEvent(dummyBlockActionEvent); + assert(fakeFunctionsSuccess.notCalled); + assert(fakeFunctionsError.called); + assert(fakeFunctionsError.calledWith({ + token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, + function_execution_id: MOCK_FUNCTION_EXECUTION_ID, + error: MOCK_FUNCTION_INPUT.message, + })); + }); }); /** From fa3116756dbca7edfbec73b39d1af50b55d5c7ee Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Thu, 20 Jun 2024 10:17:35 -0700 Subject: [PATCH 8/9] test: confirm false attachments of bot tokens is correct on its own --- src/App-custom-function.spec.ts | 51 +++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 9 deletions(-) diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts index 77d426e0e..6669deae5 100644 --- a/src/App-custom-function.spec.ts +++ b/src/App-custom-function.spec.ts @@ -71,7 +71,7 @@ describe('App CustomFunction middleware', () => { app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, inputs }) => { const response = await complete({ outputs: inputs }); - assert(response?.ok); + assert(response.ok); assert.equal(client.token, MOCK_FUNCTION_BOT_ACCESS_TOKEN); }); @@ -87,13 +87,12 @@ describe('App CustomFunction middleware', () => { })); }); - it('completes a function with error after middleware using application settings', async () => { + it('completes a function with error after running listener middleware', async () => { const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); const app = new MockApp({ authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), receiver: fakeReceiver, - attachFunctionToken: false, }); app.function(MOCK_FUNCTION_CALLBACK_ID, @@ -101,10 +100,9 @@ describe('App CustomFunction middleware', () => { context.example = '12'; await next(); }, - async ({ client, context, fail }) => { + async ({ context, fail }) => { const response = await fail({ error: context.example }); - assert(response?.ok); - assert.equal(client.token, MOCK_BOT_TOKEN); + assert(response.ok); }); assert(fakeFunctionsError.notCalled); @@ -113,12 +111,47 @@ describe('App CustomFunction middleware', () => { assert(fakeFunctionsSuccess.notCalled); assert(fakeFunctionsError.calledOnce); assert(fakeFunctionsError.calledWith({ - token: MOCK_BOT_TOKEN, + token: MOCK_FUNCTION_BOT_ACCESS_TOKEN, function_execution_id: MOCK_FUNCTION_EXECUTION_ID, error: '12', })); }); + it('uses the application bot token when not attaching the function token', async () => { + const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); + + const app = new MockApp({ + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), + receiver: fakeReceiver, + attachFunctionToken: false, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, fail }) => { + const completed = await complete(); + assert(completed.ok); + const failed = await fail({ error: 'ohno' }); + assert(failed.ok); + assert.equal(client.token, MOCK_BOT_TOKEN); + }); + + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.notCalled); + await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); + assert(fakeFunctionsSuccess.calledOnce); + assert(fakeFunctionsSuccess.calledBefore(fakeFunctionsError)); + assert(fakeFunctionsSuccess.calledWith({ + token: MOCK_BOT_TOKEN, + function_execution_id: MOCK_FUNCTION_EXECUTION_ID, + outputs: {}, + })); + assert(fakeFunctionsError.calledOnce); + assert(fakeFunctionsError.calledWith({ + token: MOCK_BOT_TOKEN, + function_execution_id: MOCK_FUNCTION_EXECUTION_ID, + error: 'ohno', + })); + }); + it('skips function handlers without a matching function callback id', async () => { const dummyFunctionExecutionEvent = createFunctionExecutionEvent({ mockFunctionCallbackId: 'unexpected_callback_id', @@ -151,7 +184,7 @@ describe('App CustomFunction middleware', () => { app.action(MOCK_BLOCK_ACTION_ID, async (args) => { const { context, complete } = args as any; const response = await complete(); - assert(response?.ok); + assert(response.ok); assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); assert.deepEqual(context.functionInputs, MOCK_FUNCTION_INPUT); @@ -184,7 +217,7 @@ describe('App CustomFunction middleware', () => { app.action(MOCK_BLOCK_ACTION_ID, async (args) => { const { context, fail, inputs } = args as any; const response = await fail({ error: inputs.message }); - assert(response?.ok); + assert(response.ok); assert.strictEqual(context.functionBotAccessToken, MOCK_FUNCTION_BOT_ACCESS_TOKEN); assert.strictEqual(context.functionExecutionId, MOCK_FUNCTION_EXECUTION_ID); assert.deepEqual(context.functionInputs, MOCK_FUNCTION_INPUT); From 54f36928ae8cb5cabc59e71bce4346d2c23ef019 Mon Sep 17 00:00:00 2001 From: "@zimeg" Date: Fri, 21 Jun 2024 11:04:16 -0700 Subject: [PATCH 9/9] refactor: remove repeated logic for selecting a context token --- src/App.ts | 9 +-------- src/CustomFunction.ts | 26 ++------------------------ src/WorkflowStep.ts | 6 +----- src/middleware/context.ts | 25 +++++++++++++++++++++++++ 4 files changed, 29 insertions(+), 37 deletions(-) create mode 100644 src/middleware/context.ts diff --git a/src/App.ts b/src/App.ts index c43206d55..446db1903 100644 --- a/src/App.ts +++ b/src/App.ts @@ -19,6 +19,7 @@ import { matchMessage, onlyViewActions, } from './middleware/builtin'; +import { selectToken } from './middleware/context'; import processMiddleware from './middleware/process'; import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; import { WorkflowStep } from './WorkflowStep'; @@ -1590,14 +1591,6 @@ function isBlockActionOrInteractiveMessageBody( return (body as SlackActionMiddlewareArgs['body']).actions !== undefined; } -// Returns a context token depending on app settings and presence for client, say() -function selectToken(context: Context): string | undefined { - if (context.functionBotAccessToken) { - return context.functionBotAccessToken; - } - return context.botToken !== undefined ? context.botToken : context.userToken; -} - function buildRespondFn( axiosInstance: AxiosInstance, responseUrl: string, diff --git a/src/CustomFunction.ts b/src/CustomFunction.ts index 744c06d34..2d85d77a1 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -18,6 +18,7 @@ import { FunctionFailArguments, Middleware, } from './types'; +import { selectToken } from './middleware/context'; import processMiddleware from './middleware/process'; import { StringIndexed } from './types/helpers'; @@ -177,7 +178,7 @@ export default class CustomFunction { if (functionExecutionId === undefined) { throw new CustomFunctionRuntimeError('No function_execution_id was found in the context'); } - const token = CustomFunction.selectToken(context); + const token = selectToken(context); return { inputs: functionInputs ?? {}, complete: (params: FunctionCompleteArguments = {}): Promise => ( @@ -196,27 +197,4 @@ export default class CustomFunction { ), }; } - - /** - * Gather a token matching the function context set during app initialization. - * This is either from the function execution context or a bot or user token and - * is the token used in `function()` handlers. - * - * The functionBotAccessToken is set to context during `App` initialization if - * configured during setup so that continuity between a function_executed event - * and subsequent interactive events (actions) is preserved. - * - * The botToken and userToken remain available in context regardless of this but - * the functionBotAccess token cannot be switched between functions. Middleware - * granularity for this decision is left TODO. - * - * @param context - the incoming payload context. - * @link https://github.com/slackapi/bolt-js/pull/2026#discussion_r1467123047 - */ - private static selectToken(context: Context): string | undefined { - if (context.functionBotAccessToken) { - return context.functionBotAccessToken; - } - return context.botToken ?? context.userToken; - } } diff --git a/src/WorkflowStep.ts b/src/WorkflowStep.ts index ed9c36ab7..b99d8eaed 100644 --- a/src/WorkflowStep.ts +++ b/src/WorkflowStep.ts @@ -14,11 +14,11 @@ import { SlackActionMiddlewareArgs, SlackViewMiddlewareArgs, WorkflowStepEdit, - Context, SlackEventMiddlewareArgs, ViewWorkflowStepSubmitAction, WorkflowStepExecuteEvent, } from './types'; +import { selectToken } from './middleware/context'; import processMiddleware from './middleware/process'; import { WorkflowStepInitializationError } from './errors'; @@ -247,10 +247,6 @@ export function isStepEvent(args: AnyMiddlewareArgs): args is AllWorkflowStepMid return VALID_PAYLOAD_TYPES.has(args.payload.type); } -function selectToken(context: Context): string | undefined { - return context.botToken !== undefined ? context.botToken : context.userToken; -} - /** * Factory for `configure()` utility * @param args workflow_step_edit action diff --git a/src/middleware/context.ts b/src/middleware/context.ts new file mode 100644 index 000000000..6608efcb1 --- /dev/null +++ b/src/middleware/context.ts @@ -0,0 +1,25 @@ +/* eslint-disable import/prefer-default-export */ +import { Context } from '../types'; + +/** + * Gather a token from the invocation context as set during app initialization. + * This is either from the function execution context or a bot or user token and + * is the token used in handlers. + * + * The functionBotAccessToken is set in context during `App` initialization if + * configured during setup so that continuity between a function_executed event + * and subsequent interactive events (actions) is preserved. + * + * The botToken and userToken remain available in context regardless of this but + * the functionBotAccess token cannot be switched between functions. Middleware + * granularity for this decision is left TODO. + * + * @param context - the incoming payload context. + * @link https://github.com/slackapi/bolt-js/pull/2026#discussion_r1467123047 + */ +export function selectToken(context: Context): string | undefined { + if (context.functionBotAccessToken) { + return context.functionBotAccessToken; + } + return context.botToken ?? context.userToken; +}