diff --git a/.github/workflows/samples.yml b/.github/workflows/samples.yml new file mode 100644 index 000000000..9d0a0f2ba --- /dev/null +++ b/.github/workflows/samples.yml @@ -0,0 +1,41 @@ +# This workflow runs a TypeScript compilation against slack sample apps built on top of bolt-js +name: Samples Integration Type-checking + +on: + push: + branches: [main] + pull_request: + +jobs: + samples: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [18.x, 20.x, 22.x] + sample: + - slack-samples/bolt-ts-starter-template + + steps: + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - name: Checkout bolt-js + uses: actions/checkout@v4 + with: + path: ./bolt-js + - name: Install and link bolt-js + working-directory: ./bolt-js + run: npm i && npm link . + - name: Checkout ${{ matrix.sample }} + uses: actions/checkout@v4 + with: + repository: ${{ matrix.sample }} + path: ./sample + - name: Install sample dependencies and link bolt-js + working-directory: ./sample + run: npm i && npm link @slack/bolt + - name: Compile sample + working-directory: ./sample + run: npx tsc + diff --git a/package.json b/package.json index 9f23390d7..fb82cbecd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@slack/bolt", - "version": "3.20.0", + "version": "3.20.0-customFunctionBeta.1", "description": "A framework for building Slack apps, fast.", "author": "Slack Technologies, LLC", "license": "MIT", @@ -30,7 +30,8 @@ "build:clean": "shx rm -rf ./dist ./coverage ./.nyc_output", "lint": "eslint --fix --ext .ts src", "mocha": "TS_NODE_PROJECT=tsconfig.json nyc mocha --config .mocharc.json \"src/**/*.spec.ts\"", - "test": "npm run lint && npm run mocha && npm run test:types", + "test": "npm run build && npm run lint && npm run mocha && npm run test:types", + "test:coverage": "npm run mocha && nyc report --reporter=text", "test:types": "tsd", "watch": "npx nodemon --watch 'src' --ext 'ts' --exec npm run build" }, @@ -44,7 +45,7 @@ "@slack/oauth": "^2.6.2", "@slack/socket-mode": "^1.3.3", "@slack/types": "^2.11.0", - "@slack/web-api": "^6.11.2", + "@slack/web-api": "^6.12.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", diff --git a/src/App.ts b/src/App.ts index 5fea2290c..184063f81 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'; @@ -61,6 +62,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'; // 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 @@ -108,6 +110,7 @@ export interface AppOptions { tokenVerificationEnabled?: boolean; deferInitialization?: boolean; extendedErrorHandler?: boolean; + attachFunctionToken?: boolean; } export { LogLevel, Logger } from '@slack/logger'; @@ -268,6 +271,8 @@ export default class App private initialized: boolean; + private attachFunctionToken: boolean; + public constructor({ signingSecret = undefined, endpoints = undefined, @@ -300,6 +305,7 @@ export default class App tokenVerificationEnabled = true, extendedErrorHandler = false, deferInitialization = false, + attachFunctionToken = true, }: AppOptions = {}) { /* ------------------------ Developer mode ----------------------------- */ this.developerMode = developerMode; @@ -332,6 +338,9 @@ export default class App this.errorHandler = defaultErrorHandler(this.logger) as AnyErrorHandler; this.extendedErrorHandler = extendedErrorHandler; + // Override token with functionBotAccessToken in function-related handlers + this.attachFunctionToken = attachFunctionToken; + /* ------------------------ Set client options ------------------------*/ this.clientOptions = clientOptions !== undefined ? clientOptions : {}; if (agent !== undefined && this.clientOptions.agent === undefined) { @@ -494,6 +503,10 @@ export default class App } } + public get webClientOptions(): WebClientOptions { + return this.clientOptions; + } + /** * Register a new middleware, processed in the order registered. * @@ -517,6 +530,16 @@ export default class App return this; } + /** + * Register CustomFunction middleware + */ + public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this { + const fn = new CustomFunction(callbackId, listeners, this.webClientOptions); + const m = fn.getMiddleware(); + this.middleware.push(m); + return this; + } + /** * Convenience method to call start on the receiver * @@ -946,6 +969,18 @@ export default class App retryReason: event.retryReason, }; + // Extract function-related information and augment context + const { functionExecutionId, functionBotAccessToken, functionInputs } = extractFunctionContext(body); + if (functionExecutionId) { + context.functionExecutionId = functionExecutionId; + if (functionInputs) { context.functionInputs = functionInputs; } + } + + // Attach and make available the JIT/function-related token on context + if (this.attachFunctionToken) { + if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; } + } + // Factory for say() utility const createSay = (channelId: string): SayFn => { const token = selectToken(context); @@ -1001,6 +1036,9 @@ export default class App /** Ack function might be set below */ // eslint-disable-next-line @typescript-eslint/no-explicit-any ack?: AckFn; + complete?: FunctionCompleteFn; + fail?: FunctionFailFn; + inputs?: FunctionInputs; } = { body: bodyArg, payload, @@ -1054,7 +1092,18 @@ export default class App // Get the client arg let { client } = this; - const token = selectToken(context); + + // If functionBotAccessToken exists on context, the incoming event is function-related *and* the + // user has `attachFunctionToken` enabled. In that case, subsequent calls with the client should + // use the function-related/JIT token in lieu of the botToken or userToken. + const token = context.functionBotAccessToken ? context.functionBotAccessToken : selectToken(context); + + // Add complete() and fail() utilities for function-related interactivity + if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) { + listenerArgs.complete = CustomFunction.createFunctionComplete(context, client); + listenerArgs.fail = CustomFunction.createFunctionFail(context, client); + listenerArgs.inputs = context.functionInputs; + } if (token !== undefined) { let pool; @@ -1562,6 +1611,27 @@ 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 new file mode 100644 index 000000000..24dc00059 --- /dev/null +++ b/src/CustomFunction.spec.ts @@ -0,0 +1,301 @@ +import 'mocha'; +import { assert } from 'chai'; +import sinon from 'sinon'; +import rewiremock from 'rewiremock'; +import { WebClient } from '@slack/web-api'; +import { + CustomFunction, + SlackCustomFunctionMiddlewareArgs, + AllCustomFunctionMiddlewareArgs, + CustomFunctionMiddleware, + CustomFunctionExecuteMiddlewareArgs, +} from './CustomFunction'; +import { createFakeLogger, Override } from './test-helpers'; +import { AllMiddlewareArgs, Middleware } from './types'; +import { CustomFunctionInitializationError } from './errors'; + +async function importCustomFunction(overrides: Override = {}): Promise { + return rewiremock.module(() => import('./CustomFunction'), overrides); +} + +const MOCK_FN = async () => {}; +const MOCK_FN_2 = async () => {}; + +const MOCK_MIDDLEWARE_SINGLE = [MOCK_FN]; +const MOCK_MIDDLEWARE_MULTIPLE = [MOCK_FN, MOCK_FN_2]; + +describe('CustomFunction class', () => { + describe('constructor', () => { + it('should accept single function as middleware', async () => { + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); + assert.isNotNull(fn); + }); + + it('should accept multiple functions as middleware', async () => { + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE, {}); + assert.isNotNull(fn); + }); + }); + + describe('getMiddleware', () => { + it('should not call next if a function_executed event', async () => { + const cbId = 'test_executed_callback_id'; + const fn = new CustomFunction(cbId, MOCK_MIDDLEWARE_SINGLE, {}); + const middleware = fn.getMiddleware(); + const fakeEditArgs = createFakeFunctionExecutedEvent(cbId); + + const fakeNext = sinon.spy(); + fakeEditArgs.next = fakeNext; + + await middleware(fakeEditArgs); + + assert(fakeNext.notCalled, 'next called!'); + }); + + it('should call next if valid custom function but mismatched callback_id', async () => { + const fn = new CustomFunction('bad_executed_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); + const middleware = fn.getMiddleware(); + const fakeEditArgs = createFakeFunctionExecutedEvent(); + + 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', () => { + it('should throw an error if callback_id is not valid', async () => { + const { validate } = await importCustomFunction(); + + // intentionally casting to string to trigger failure + const badId = {} as string; + const validationFn = () => validate(badId, MOCK_MIDDLEWARE_SINGLE); + + const expectedMsg = 'CustomFunction expects a callback_id as the first argument'; + assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + }); + + it('should throw an error if middleware is not a function or array', async () => { + const { validate } = await importCustomFunction(); + + // intentionally casting to CustomFunctionMiddleware to trigger failure + const badConfig = '' as unknown as CustomFunctionMiddleware; + + const validationFn = () => validate('callback_id', badConfig); + const expectedMsg = 'CustomFunction expects a function or array of functions as the second argument'; + assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + }); + + it('should throw an error if middleware is not a single callback or an array of callbacks', async () => { + const { validate } = await importCustomFunction(); + + // intentionally casting to CustomFunctionMiddleware to trigger failure + const badMiddleware = [ + async () => {}, + 'not-a-function', + ] as unknown as CustomFunctionMiddleware; + + const validationFn = () => validate('callback_id', badMiddleware); + const expectedMsg = 'All CustomFunction middleware must be functions'; + assert.throws(validationFn, CustomFunctionInitializationError, expectedMsg); + }); + }); + + describe('isFunctionEvent', () => { + it('should return true if recognized function_executed payload type', async () => { + const fakeExecuteArgs = createFakeFunctionExecutedEvent(); + + 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(); + // @ts-expect-error expected invalid payload type + fakeExecutedEvent.payload.type = 'invalid_type'; + + const { isFunctionEvent } = await importCustomFunction(); + const eventIsFunctionExecuted = isFunctionEvent(fakeExecutedEvent); + + assert.isFalse(eventIsFunctionExecuted); + }); + }); + + describe('enrichFunctionArgs', () => { + it('should remove next() from all original event args', async () => { + const fakeExecutedEvent = createFakeFunctionExecutedEvent(); + + 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', () => { + describe('`complete` factory function', () => { + it('complete should call functions.completeSuccess', async () => { + const client = new WebClient('sometoken'); + const completeMock = sinon.stub(client.functions, 'completeSuccess').resolves(); + const complete = CustomFunction.createFunctionComplete({ isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, client); + await complete(); + assert(completeMock.called, 'client.functions.completeSuccess not called!'); + }); + it('should throw if no functionExecutionId present on context', () => { + const client = new WebClient('sometoken'); + assert.throws(() => { + CustomFunction.createFunctionComplete({ isEnterpriseInstall: false }, client); + }); + }); + }); + + describe('`fail` factory function', () => { + it('fail should call functions.completeError', async () => { + const client = new WebClient('sometoken'); + const completeMock = sinon.stub(client.functions, 'completeError').resolves(); + const complete = CustomFunction.createFunctionFail({ isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, client); + await complete({ error: 'boom' }); + assert(completeMock.called, 'client.functions.completeError not called!'); + }); + it('should throw if no functionExecutionId present on context', () => { + const client = new WebClient('sometoken'); + assert.throws(() => { + CustomFunction.createFunctionFail({ isEnterpriseInstall: false }, client); + }); + }); + }); + + it('inputs should map to function payload inputs', async () => { + const fakeExecuteArgs = createFakeFunctionExecutedEvent(); + + 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(); + 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, 'first user-provided middleware not called!'); + assert(fn2.called, 'second user-provided middleware not called!'); + }); + }); +}); + +function createFakeFunctionExecutedEvent(callbackId?: string): AllCustomFunctionMiddlewareArgs { + const func = { + type: 'function', + id: 'somefunc', + callback_id: callbackId || 'callback_id', + title: 'My dope function', + input_parameters: [], + output_parameters: [], + app_id: 'A1234', + date_created: 123456, + date_deleted: 0, + date_updated: 123456, + }; + const base = { + bot_access_token: 'xoxb-abcd-1234', + event_ts: '123456.789', + function_execution_id: 'Fx1234', + workflow_execution_id: 'Wf1234', + type: 'function_executed', + } as const; + const inputs = { message: 'test123', recipient: 'U012345' }; + const event = { + function: func, + inputs, + ...base, + } as const; + return { + body: { + api_app_id: 'A1234', + event, + event_id: 'E1234', + event_time: 123456, + team_id: 'T1234', + token: 'xoxb-1234', + type: 'event_callback', + }, + client: new WebClient('faketoken'), + complete: () => Promise.resolve({ ok: true }), + context: { + functionBotAccessToken: 'xwfp-123', + functionExecutionId: 'test_executed_callback_id', + isEnterpriseInstall: false, + }, + event, + fail: () => Promise.resolve({ ok: true }), + inputs, + logger: createFakeLogger(), + message: undefined, + next: () => Promise.resolve(), + payload: { + function: func, + inputs: { message: 'test123', recipient: 'U012345' }, + ...base, + }, + say: undefined, + }; +} + +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 new file mode 100644 index 000000000..4033bc0d9 --- /dev/null +++ b/src/CustomFunction.ts @@ -0,0 +1,225 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { + WebClient, + FunctionsCompleteErrorResponse, + FunctionsCompleteSuccessResponse, + WebClientOptions, +} from '@slack/web-api'; +import { + Middleware, + AllMiddlewareArgs, + AnyMiddlewareArgs, + SlackEventMiddlewareArgs, + Context, + FunctionExecutedEvent, +} 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 */ + +const VALID_PAYLOAD_TYPES = new Set(['function_executed']); + +/** Class */ + +export class CustomFunction { + /** Function callback_id */ + public callbackId: string; + + private appWebClientOptions: WebClientOptions; + + private middleware: CustomFunctionMiddleware; + + public constructor( + callbackId: string, + middleware: CustomFunctionExecuteMiddleware, + clientOptions: WebClientOptions, + ) { + validate(callbackId, middleware); + + this.appWebClientOptions = clientOptions; + this.callbackId = callbackId; + this.middleware = middleware; + } + + 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, this.appWebClientOptions); + const functionMiddleware = this.getFunctionMiddleware(); + return processFunctionMiddleware(functionArgs, functionMiddleware); + } + + private getFunctionMiddleware(): CustomFunctionMiddleware { + return this.middleware; + } + + /** + * Factory for `complete()` utility + */ + public static createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn { + const token = selectToken(context); + const { functionExecutionId } = context; + + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteSuccessError(errorMsg); + } + + return (params: Parameters[0] = {}) => client.functions.completeSuccess({ + token, + outputs: params.outputs || {}, + function_execution_id: functionExecutionId, + }); + } + + /** + * Factory for `fail()` utility + */ + public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn { + const token = selectToken(context); + const { functionExecutionId } = context; + + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteFailError(errorMsg); + } + + return (params: Parameters[0]) => { + const { error } = params ?? {}; + + return client.functions.completeError({ + token, + error, + function_execution_id: functionExecutionId, + }); + }; + } +} + +/** 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); + } + + // Ensure middleware argument is either a function or an array + if (typeof middleware !== 'function' && !Array.isArray(middleware)) { + const errorMsg = 'CustomFunction expects a function or array of functions as the second argument'; + throw new CustomFunctionInitializationError(errorMsg); + } + + // Ensure array includes only functions + if (Array.isArray(middleware)) { + middleware.forEach((fn) => { + if (!(fn instanceof Function)) { + const errorMsg = 'All CustomFunction middleware must be functions'; + throw new CustomFunctionInitializationError(errorMsg); + } + }); + } +} + +/** + * `processFunctionMiddleware()` invokes each listener middleware + */ +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 }), + ); + } +} + +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: AllCustomFunctionMiddlewareArgs, webClientOptions: WebClientOptions, +): AllCustomFunctionMiddlewareArgs { + const { next: _next, ...functionArgs } = args; + const enrichedArgs = { ...functionArgs }; + const token = selectToken(functionArgs.context); + + // Making calls with a functionBotAccessToken establishes continuity between + // a function_executed event and subsequent interactive events (actions) + const client = new WebClient(token, webClientOptions); + 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 as AllCustomFunctionMiddlewareArgs; // TODO: dangerous casting as it obfuscates missing `next()` +} diff --git a/src/errors.ts b/src/errors.ts index 688c74d78..09a5a8b7f 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -39,6 +39,10 @@ export enum ErrorCode { UnknownError = 'slack_bolt_unknown_error', 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 { @@ -143,3 +147,15 @@ export class MultipleListenerError extends Error implements CodedError { export class WorkflowStepInitializationError extends Error implements CodedError { public code = ErrorCode.WorkflowStepInitializationError; } + +export class CustomFunctionInitializationError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionInitializationError; +} + +export class CustomFunctionCompleteSuccessError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionCompleteSuccessError; +} + +export class CustomFunctionCompleteFailError extends Error implements CodedError { + public code = ErrorCode.CustomFunctionCompleteFailError; +} diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 69f908c12..6152c3af4 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -299,15 +299,19 @@ export function ignoreSelf(): Middleware { const botUserId = args.context.botUserId !== undefined ? (args.context.botUserId as string) : undefined; if (isEventArgs(args)) { - // Once we've narrowed the type down to SlackEventMiddlewareArgs, there's no way to further narrow it down to - // SlackEventMiddlewareArgs<'message'> without a cast, so the following couple lines do that. - if (args.message !== undefined) { - const message = args.message as SlackEventMiddlewareArgs<'message'>['message']; + if (args.event.type === 'message') { + // Once we've narrowed the type down to SlackEventMiddlewareArgs, there's no way to further narrow it down to + // SlackEventMiddlewareArgs<'message'> without a cast, so the following couple lines do that. + // TODO: there must be a better way; generics-based types for event and middleware arguments likely the issue + // should instead use a discriminated union + const message = args.message as unknown as SlackEventMiddlewareArgs<'message'>['message']; + if (message !== undefined) { // TODO: revisit this once we have all the message subtypes defined to see if we can do this better with // type narrowing // Look for an event that is identified as a bot message from the same bot ID as this app, and return to skip - if (message.subtype === 'bot_message' && message.bot_id === botId) { - return; + if (message.subtype === 'bot_message' && message.bot_id === botId) { + return; + } } } @@ -331,7 +335,7 @@ export function ignoreSelf(): Middleware { */ export function subtype(subtype1: string): Middleware> { return async ({ message, next }) => { - if (message.subtype === subtype1) { + if (message && message.subtype === subtype1) { await next(); } }; @@ -354,7 +358,7 @@ export function directMention(): Middleware> ); } - if (!('text' in message) || message.text === undefined) { + if (!message || !('text' in message) || message.text === undefined) { return; } @@ -406,6 +410,8 @@ function isViewBody( return (body as SlackViewAction).view !== undefined; } -function isEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewareArgs { +function isEventArgs( + args: AnyMiddlewareArgs, +): args is SlackEventMiddlewareArgs { return (args as SlackEventMiddlewareArgs).event !== undefined; } diff --git a/src/middleware/process.ts b/src/middleware/process.ts index b183f46b6..cc963d8f3 100644 --- a/src/middleware/process.ts +++ b/src/middleware/process.ts @@ -21,8 +21,8 @@ export default async function processMiddleware( if (toCallMiddlewareIndex < middleware.length) { lastCalledMiddlewareIndex = toCallMiddlewareIndex; return middleware[toCallMiddlewareIndex]({ - next: () => invokeMiddleware(toCallMiddlewareIndex + 1), ...initialArgs, + next: () => invokeMiddleware(toCallMiddlewareIndex + 1), context, client, logger, diff --git a/src/types/actions/index.ts b/src/types/actions/index.ts index c94a8673c..5c7e7841a 100644 --- a/src/types/actions/index.ts +++ b/src/types/actions/index.ts @@ -3,6 +3,8 @@ import { InteractiveMessage } from './interactive-message'; import { WorkflowStepEdit } from './workflow-step-edit'; import { DialogSubmitAction, DialogValidation } from './dialog-action'; import { SayFn, SayArguments, RespondFn, AckFn } from '../utilities'; +import { FunctionCompleteFn, FunctionFailFn } from '../../CustomFunction'; +import { FunctionInputs } from '../events'; export * from './block-action'; export * from './interactive-message'; @@ -42,9 +44,12 @@ export interface SlackActionMiddlewareArgs ? SayFn : never; + say: Action extends Exclude ? SayFn : undefined; respond: RespondFn; ack: ActionAckFn; + complete?: FunctionCompleteFn; + fail?: FunctionFailFn; + inputs?: FunctionInputs; } /** diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index 86b70c820..a50800534 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -38,6 +38,7 @@ export type SlackEvent = | FilePublicEvent | FileSharedEvent | FileUnsharedEvent + | FunctionExecutedEvent | GridMigrationFinishedEvent | GridMigrationStartedEvent | GroupArchiveEvent @@ -492,6 +493,42 @@ 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; + type: string; + input_parameters: FunctionParams[]; + output_parameters: FunctionParams[]; + app_id: string; + date_created: number; + date_updated: number; + date_deleted: number + }; + inputs: FunctionInputs; + function_execution_id: string; + workflow_execution_id: string; + event_ts: string; + bot_access_token: string; +} + export interface GridMigrationFinishedEvent { type: 'grid_migration_finished'; enterprise_id: string; diff --git a/src/types/events/index.ts b/src/types/events/index.ts index b888b4a00..0b2c0ef47 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -29,11 +29,11 @@ export { export interface SlackEventMiddlewareArgs { payload: EventFromType; event: this['payload']; - message: EventType extends 'message' ? this['payload'] : never; + message: EventType extends 'message' ? this['payload'] : undefined; body: EnvelopedEvent; say: WhenEventHasChannelContext; // Add `ack` as undefined for global middleware in TypeScript - ack: undefined; + ack?: undefined; } /** @@ -78,8 +78,8 @@ export type KnownEventFromType = Extract = Event extends { channel: string } | { item: { channel: string } } ? Type - : never; + : undefined; diff --git a/src/types/helpers.ts b/src/types/helpers.ts index 3a7a8bb9d..124f8423b 100644 --- a/src/types/helpers.ts +++ b/src/types/helpers.ts @@ -1,9 +1,11 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +// TODO: breaking change: remove, unnecessary abstraction, just use Record directly /** * Extend this interface to build a type that is treated as an open set of properties, where each key is a string. */ export type StringIndexed = Record; +// TODO: breaking change: no longer used! remove /** * @deprecated No longer works in TypeScript 4.3 */ diff --git a/src/types/middleware.ts b/src/types/middleware.ts index a36fddc62..d1d8878df 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -1,7 +1,7 @@ import { WebClient } from '@slack/web-api'; import { Logger } from '@slack/logger'; import { StringIndexed } from './helpers'; -import { SlackEventMiddlewareArgs } from './events'; +import { FunctionInputs, SlackEventMiddlewareArgs } from './events'; import { SlackActionMiddlewareArgs } from './actions'; import { SlackCommandMiddlewareArgs } from './command'; import { SlackOptionsMiddlewareArgs } from './options'; @@ -73,6 +73,23 @@ export interface Context extends StringIndexed { */ isEnterpriseInstall: boolean, + /** + * A JIT and function-specific token that, when used to make API calls, + * creates an association between a function's execution and subsequent actions + * (e.g., buttons and other interactivity) + */ + functionBotAccessToken?: string; + + /** + * Function execution ID associated with the event + */ + functionExecutionId?: string; + + /** + * Inputs that were provided to a function when it was executed + */ + functionInputs?: FunctionInputs; + /** * Retry count of an Events API request (this property does not exist for other requests) */ @@ -90,6 +107,9 @@ export const contextBuiltinKeys: string[] = [ 'botUserId', 'teamId', 'enterpriseId', + 'functionBotAccessToken', + 'functionExecutionId', + 'functionInputs', 'retryNum', 'retryReason', ]; diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index 776c2cfb7..d554c4b36 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -14,6 +14,7 @@ ".eslintrc.js", "docs/**/*", "examples/**/*", + "types-tests/**/*" ], "exclude": [ // Overwrite exclude from the base config to clear the value @@ -21,6 +22,5 @@ // Contains external module type definitions, which are not subject to this project's style rules "types/**/*", // Contain intentional type checking issues for the purpose of testing the typechecker's output - "types-tests/**/*" ] } diff --git a/types-tests/event.test-d.ts b/types-tests/event.test-d.ts index b24a6687a..06a27ab8f 100644 --- a/types-tests/event.test-d.ts +++ b/types-tests/event.test-d.ts @@ -8,7 +8,7 @@ expectType( expectType(event); expectNotType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -16,7 +16,7 @@ expectType( expectType(event); expectNotType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -24,7 +24,7 @@ expectType( expectType(event); expectNotType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -32,7 +32,7 @@ expectType( expectType(event); expectNotType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -40,7 +40,7 @@ expectType( expectType(event); expectNotType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -48,7 +48,7 @@ expectType( expectType(event); expectNotType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -56,7 +56,7 @@ expectType( expectType(say); expectType(event); await Promise.resolve(event); - }) + }), ); expectType( @@ -64,19 +64,19 @@ expectType( expectType(say); expectType(event); await Promise.resolve(event); - }) + }), ); expectType( app.event('reaction_added', async ({ say, event }) => { expectType(say); await Promise.resolve(event); - }) + }), ); expectType( app.event('reaction_removed', async ({ say, event }) => { expectType(say); await Promise.resolve(event); - }) + }), );