diff --git a/src/App-custom-function.spec.ts b/src/App-custom-function.spec.ts new file mode 100644 index 000000000..6669deae5 --- /dev/null +++ b/src/App-custom-function.spec.ts @@ -0,0 +1,341 @@ +import 'mocha'; +import { assert } from 'chai'; +import rewiremock from 'rewiremock'; +import sinon, { SinonSpy } from 'sinon'; +import { 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'; + +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 }); + + 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(); + + const app = new MockApp({ + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), + receiver: fakeReceiver, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, async ({ client, complete, inputs }) => { + const 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 running listener middleware', async () => { + const dummyFunctionExecutionEvent = createFunctionExecutionEvent(); + + const app = new MockApp({ + authorize: sinon.fake.resolves(MOCK_AUTHORIZATION_RESULT), + receiver: fakeReceiver, + }); + + app.function(MOCK_FUNCTION_CALLBACK_ID, + async ({ context, next }) => { + context.example = '12'; + await next(); + }, + async ({ context, fail }) => { + const response = await fail({ error: context.example }); + assert(response.ok); + }); + + assert(fakeFunctionsError.notCalled); + assert(fakeFunctionsSuccess.notCalled); + await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); + assert(fakeFunctionsSuccess.notCalled); + assert(fakeFunctionsError.calledOnce); + assert(fakeFunctionsError.calledWith({ + 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', + }); + + 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`); + }); + + await fakeReceiver.sendEvent(dummyFunctionExecutionEvent); + }); + + it('extracts function execution context for success 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, complete } = args as any; + const 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: {}, + })); + }); + + 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, + })); + }); +}); + +/** + * 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 9902de838..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'; @@ -54,6 +55,10 @@ import { SlashCommand, WorkflowStepEdit, SlackOptions, + CustomFunctionMiddleware, + FunctionCompleteFn, + FunctionFailFn, + FunctionInputs, } from './types'; import { IncomingEventType, getTypeAndConversation, assertNever, isBodyWithTypeEnterpriseInstall, isEventTypeToSkipAuthorize } from './helpers'; import { CodedError, asCodedError, AppInitializationError, MultipleListenerError, ErrorCode, InvalidCustomPropertyError } from './errors'; @@ -61,7 +66,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 @@ -109,6 +114,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; } @@ -526,10 +537,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; @@ -962,16 +989,9 @@ export default class App isEnterpriseInstall, retryNum: event.retryNum, retryReason: event.retryReason, + ...CustomFunction.extractContext(body, this.attachFunctionToken), }; - // Extract function-related information and augment to context - const { functionExecutionId, functionBotAccessToken } = extractFunctionContext(body); - if (functionExecutionId) { context.functionExecutionId = functionExecutionId; } - - if (this.attachFunctionToken) { - if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } - } - // Factory for say() utility const createSay = (channelId: string): SayFn => { const token = selectToken(context); @@ -1029,6 +1049,7 @@ export default class App ack?: AckFn; complete?: FunctionCompleteFn; fail?: FunctionFailFn; + inputs?: FunctionInputs; } = { body: bodyArg, payload, @@ -1080,16 +1101,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); - } - if (token !== undefined) { let pool; const clientOptionsCopy = { ...this.clientOptions }; @@ -1570,11 +1591,6 @@ function isBlockActionOrInteractiveMessageBody( return (body as SlackActionMiddlewareArgs['body']).actions !== undefined; } -// Returns either a bot token or a user token for client, say() -function selectToken(context: Context): string | undefined { - return context.botToken !== undefined ? context.botToken : context.userToken; -} - function buildRespondFn( axiosInstance: AxiosInstance, responseUrl: string, @@ -1596,25 +1612,6 @@ function escapeHtml(input: string | undefined | null): string { return ''; } -function extractFunctionContext(body: StringIndexed) { - let functionExecutionId; - let functionBotAccessToken; - - // 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; - } - - return { functionExecutionId, functionBotAccessToken }; -} - // ---------------------------- // Instrumentation // Don't change the position of the following code diff --git a/src/CustomFunction.spec.ts b/src/CustomFunction.spec.ts index 5b5a5429f..c9010cdb4 100644 --- a/src/CustomFunction.spec.ts +++ b/src/CustomFunction.spec.ts @@ -1,237 +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 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', () => { + 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', - }, - }; -} - -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 8f52c7e97..2d85d77a1 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -1,211 +1,200 @@ -/* 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 { selectToken } from './middleware/context'; import processMiddleware from './middleware/process'; -import { 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; - } - - 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; + this.client = client; } /** - * Factory for `complete()` utility - * @param args function_executed event + * 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. */ - public static createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn { - const token = selectToken(context); - const { functionExecutionId } = context; - - return (params: Parameters[0] = {}) => client.functions.completeSuccess({ - token, - outputs: params.outputs || {}, - function_execution_id: functionExecutionId, - }); + 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 `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; + * 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 extractContext(body: StringIndexed, withToken: boolean): CustomFunctionContext { + const context: CustomFunctionContext = {}; - return client.functions.completeError({ - token, - error, - function_execution_id: functionExecutionId, - }); - }; - } -} + // 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; + } + } -/** 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); - } + // 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; + } + } - // 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); + return context; } - // 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); + /** + * 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(); } - }); + const middlewareArgs = CustomFunction.middlewareArgs(args.context, this.client); + Object.assign(args, middlewareArgs); + return CustomFunction.processFunctionMiddleware(args, this.middleware); + }; } -} - -/** - * `processFunctionMiddleware()` invokes each callback for lifecycle event - * @param args workflow_step_edit action - */ -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 }), - ); + /** + * 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); } -} - -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); + /** + * 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 }), + ); + } + } - return enrichedArgs; + /** + * 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 = 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, + }) + ), + }; + } } 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/errors.ts b/src/errors.ts index c270b9c35..fa4f822b4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -41,6 +41,7 @@ export enum ErrorCode { WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', CustomFunctionInitializationError = 'slack_bolt_custom_function_initialization_error', + CustomFunctionRuntimeError = 'slack_bolt_custom_function_runtime_error', } export class UnknownError extends Error implements CodedError { @@ -149,3 +150,7 @@ export class WorkflowStepInitializationError extends Error implements CodedError export class CustomFunctionInitializationError extends Error implements CodedError { public code = ErrorCode.CustomFunctionInitializationError; } + +export class CustomFunctionRuntimeError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionRuntimeError; +} 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; +} 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 a36fddc62..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 { 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), @@ -92,6 +94,7 @@ export const contextBuiltinKeys: string[] = [ 'enterpriseId', 'retryNum', 'retryReason', + ...Array(), ]; export type NextFn = () => Promise;