diff --git a/src/App.ts b/src/App.ts index f346a11da..3084c61a2 100644 --- a/src/App.ts +++ b/src/App.ts @@ -7,9 +7,11 @@ import axios, { type AxiosInstance, type AxiosResponse } from 'axios'; import type { Assistant } from './Assistant'; import { CustomFunction, - type CustomFunctionMiddleware, type FunctionCompleteFn, type FunctionFailFn, + type SlackCustomFunctionMiddlewareArgs, + createFunctionComplete, + createFunctionFail, } from './CustomFunction'; import type { WorkflowStep } from './WorkflowStep'; import { type ConversationStore, MemoryStore, conversationContext } from './conversation-store'; @@ -29,7 +31,9 @@ import { isEventTypeToSkipAuthorize, } from './helpers'; import { + autoAcknowledge, ignoreSelf as ignoreSelfMiddleware, + isSlackEventMiddlewareArgsOptions, matchCommandName, matchConstraints, matchEventType, @@ -47,7 +51,6 @@ import SocketModeReceiver from './receivers/SocketModeReceiver'; import type { AckFn, ActionConstraints, - AllMiddlewareArgs, AnyMiddlewareArgs, BlockAction, BlockElementAction, @@ -72,6 +75,7 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, + SlackEventMiddlewareArgsOptions, SlackOptionsMiddlewareArgs, SlackShortcut, SlackShortcutMiddlewareArgs, @@ -82,7 +86,7 @@ import type { ViewOutput, WorkflowStepEdit, } from './types'; -import { contextBuiltinKeys } from './types'; +import { type AllMiddlewareArgs, contextBuiltinKeys } from './types/middleware'; import { type StringIndexed, isRejected } from './types/utilities'; const packageJson = require('../package.json'); @@ -496,7 +500,7 @@ export default class App * @param m global middleware function */ public use( - m: Middleware, + m: Middleware, AppCustomContext & MiddlewareCustomContext>, ): this { this.middleware.push(m as Middleware); return this; @@ -529,10 +533,31 @@ export default class App /** * 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); + public function( + callbackId: string, + options: Options, + ...listeners: Middleware>[] + ): this; + public function( + callbackId: string, + ...listeners: Middleware>[] + ): this; + public function( + callbackId: string, + ...optionOrListeners: (Options | Middleware>)[] + ): this { + // TODO: fix this casting; edge case is if dev specifically sets AutoAck generic as false, this true assignment is invalid according to TS. + const options = isSlackEventMiddlewareArgsOptions(optionOrListeners[0]) + ? optionOrListeners[0] + : ({ autoAcknowledge: true } as Options); + const listeners = optionOrListeners.filter( + (optionOrListener): optionOrListener is Middleware> => { + return !isSlackEventMiddlewareArgsOptions(optionOrListener); + }, + ); + + const fn = new CustomFunction(callbackId, listeners, options); + this.listeners.push(fn.getListeners()); return this; } @@ -594,6 +619,7 @@ export default class App this.listeners.push([ onlyEvents, matchEventType(eventNameOrPattern), + autoAcknowledge, ..._listeners, ] as Middleware[]); } @@ -662,6 +688,7 @@ export default class App this.listeners.push([ onlyEvents, matchEventType('message'), + autoAcknowledge, ...messageMiddleware, ] as Middleware[]); } @@ -979,7 +1006,7 @@ export default class App // Factory for say() utility const createSay = (channelId: string): SayFn => { - const token = selectToken(context); + const token = selectToken(context, this.attachFunctionToken); return (message) => { let postMessageArguments: ChatPostMessageArguments; if (typeof message === 'string') { @@ -1040,27 +1067,66 @@ export default class App respond?: RespondFn; /** Ack function might be set below */ // biome-ignore lint/suspicious/noExplicitAny: different kinds of acks accept different arguments, TODO: revisit this to see if we can type better - ack?: AckFn; + ack: AckFn; complete?: FunctionCompleteFn; fail?: FunctionFailFn; inputs?: FunctionInputs; } = { body: bodyArg, + ack, payload, }; + // Get the client arg + let { client } = this; + + const token = selectToken(context, this.attachFunctionToken); + + if (token !== undefined) { + let pool: WebClientPool | undefined = undefined; + const clientOptionsCopy = { ...this.clientOptions }; + if (authorizeResult.teamId !== undefined) { + pool = this.clients[authorizeResult.teamId]; + if (pool === undefined) { + pool = this.clients[authorizeResult.teamId] = new WebClientPool(); + } + // Add teamId to clientOptions so it can be automatically added to web-api calls + clientOptionsCopy.teamId = authorizeResult.teamId; + } else if (authorizeResult.enterpriseId !== undefined) { + pool = this.clients[authorizeResult.enterpriseId]; + if (pool === undefined) { + pool = this.clients[authorizeResult.enterpriseId] = new WebClientPool(); + } + } + if (pool !== undefined) { + client = pool.getOrCreate(token, clientOptionsCopy); + } + } + // TODO: can we instead use type predicates in these switch cases to allow for narrowing of the body simultaneously? we have isEvent, isView, isShortcut, isAction already in types/utilities / helpers // Set aliases if (type === IncomingEventType.Event) { - const eventListenerArgs = listenerArgs as SlackEventMiddlewareArgs; + // TODO: assigning eventListenerArgs by reference to set properties of listenerArgs is error prone, there should be a better way to do this! + const eventListenerArgs = listenerArgs as unknown as SlackEventMiddlewareArgs; eventListenerArgs.event = eventListenerArgs.payload; if (eventListenerArgs.event.type === 'message') { const messageEventListenerArgs = eventListenerArgs as SlackEventMiddlewareArgs<'message'>; messageEventListenerArgs.message = messageEventListenerArgs.payload; } + if (eventListenerArgs.event.type === 'function_executed') { + listenerArgs.complete = createFunctionComplete(context, client); + listenerArgs.fail = createFunctionFail(context, client); + listenerArgs.inputs = eventListenerArgs.event.inputs; + } } else if (type === IncomingEventType.Action) { const actionListenerArgs = listenerArgs as SlackActionMiddlewareArgs; actionListenerArgs.action = actionListenerArgs.payload; + // Add complete() and fail() utilities for function-related interactivity + if (context.functionExecutionId !== undefined) { + listenerArgs.complete = createFunctionComplete(context, client); + listenerArgs.fail = createFunctionFail(context, client); + listenerArgs.inputs = context.functionInputs; + } } else if (type === IncomingEventType.Command) { const commandListenerArgs = listenerArgs as SlackCommandMiddlewareArgs; commandListenerArgs.command = commandListenerArgs.payload; @@ -1088,50 +1154,6 @@ export default class App listenerArgs.respond = buildRespondFn(this.axios, body.response_urls[0].response_url); } - // Set ack() utility - if (type !== IncomingEventType.Event) { - listenerArgs.ack = ack; - } else { - // Events API requests are acknowledged right away, since there's no data expected - await ack(); - } - - // Get the client arg - let { client } = this; - - // 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: WebClientPool | undefined = undefined; - const clientOptionsCopy = { ...this.clientOptions }; - if (authorizeResult.teamId !== undefined) { - pool = this.clients[authorizeResult.teamId]; - if (pool === undefined) { - pool = this.clients[authorizeResult.teamId] = new WebClientPool(); - } - // Add teamId to clientOptions so it can be automatically added to web-api calls - clientOptionsCopy.teamId = authorizeResult.teamId; - } else if (authorizeResult.enterpriseId !== undefined) { - pool = this.clients[authorizeResult.enterpriseId]; - if (pool === undefined) { - pool = this.clients[authorizeResult.enterpriseId] = new WebClientPool(); - } - } - if (pool !== undefined) { - client = pool.getOrCreate(token, clientOptionsCopy); - } - } - // Dispatch event through the global middleware chain try { await processMiddleware( @@ -1575,7 +1597,15 @@ function isBlockActionOrInteractiveMessageBody( } // Returns either a bot token or a user token for client, say() -function selectToken(context: Context): string | undefined { +function selectToken(context: Context, attachFunctionToken: boolean): string | undefined { + if (attachFunctionToken) { + // 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. + if (context.functionBotAccessToken) { + return context.functionBotAccessToken; + } + } return context.botToken !== undefined ? context.botToken : context.userToken; } diff --git a/src/CustomFunction.ts b/src/CustomFunction.ts index b1d38779d..46563b2c0 100644 --- a/src/CustomFunction.ts +++ b/src/CustomFunction.ts @@ -1,17 +1,18 @@ import type { FunctionExecutedEvent } from '@slack/types'; -import { - type FunctionsCompleteErrorResponse, - type FunctionsCompleteSuccessResponse, - WebClient, - type WebClientOptions, -} from '@slack/web-api'; +import type { FunctionsCompleteErrorResponse, FunctionsCompleteSuccessResponse, WebClient } from '@slack/web-api'; import { CustomFunctionCompleteFailError, CustomFunctionCompleteSuccessError, CustomFunctionInitializationError, } from './errors'; -import processMiddleware from './middleware/process'; -import type { AllMiddlewareArgs, AnyMiddlewareArgs, Context, Middleware, SlackEventMiddlewareArgs } from './types'; +import { autoAcknowledge, matchEventType, onlyEvents } from './middleware/builtin'; +import type { + AnyMiddlewareArgs, + Context, + Middleware, + SlackEventMiddlewareArgs, + SlackEventMiddlewareArgsOptions, +} from './types'; /** Interfaces */ @@ -20,6 +21,8 @@ interface FunctionCompleteArguments { outputs?: Record; } +/** Types */ + export type FunctionCompleteFn = (params?: FunctionCompleteArguments) => Promise; interface FunctionFailArguments { @@ -28,115 +31,70 @@ interface FunctionFailArguments { export type FunctionFailFn = (params: FunctionFailArguments) => Promise; -export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { +export type SlackCustomFunctionMiddlewareArgs< + Options extends SlackEventMiddlewareArgsOptions = { autoAcknowledge: true }, +> = SlackEventMiddlewareArgs<'function_executed', Options> & { inputs: FunctionExecutedEvent['inputs']; complete: FunctionCompleteFn; fail: FunctionFailFn; -} - -/** Types */ - -export type SlackCustomFunctionMiddlewareArgs = CustomFunctionExecuteMiddlewareArgs; - -type CustomFunctionExecuteMiddleware = Middleware[]; - -export type CustomFunctionMiddleware = Middleware[]; - -export type AllCustomFunctionMiddlewareArgs< - T extends SlackCustomFunctionMiddlewareArgs = SlackCustomFunctionMiddlewareArgs, -> = T & AllMiddlewareArgs; - -/** Constants */ +}; -const VALID_PAYLOAD_TYPES = new Set(['function_executed']); +/* + * Middleware that filters out function scoped events that do not match the provided callback ID + */ +export function matchCallbackId(callbackId: string): Middleware { + return async ({ payload, next }) => { + if (payload.function.callback_id === callbackId) { + await next(); + } + }; +} /** Class */ - -export class CustomFunction { +export class CustomFunction { /** Function callback_id */ public callbackId: string; - private appWebClientOptions: WebClientOptions; + private listeners: Middleware>[]; - private middleware: CustomFunctionMiddleware; + private options: Options; - public constructor(callbackId: string, middleware: CustomFunctionExecuteMiddleware, clientOptions: WebClientOptions) { - validate(callbackId, middleware); + public constructor( + callbackId: string, + listeners: Middleware>[], + options: Options, + ) { + validate(callbackId, listeners); - 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(); - }; + this.listeners = listeners; + this.options = options; } - 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); + public getListeners(): Middleware[] { + if (this.options.autoAcknowledge) { + return [ + onlyEvents, + matchEventType('function_executed'), + matchCallbackId(this.callbackId), + autoAcknowledge, + ...this.listeners, + ] as Middleware[]; } - - return (params: Parameters[0]) => { - const { error } = params ?? {}; - - return client.functions.completeError({ - token, - error, - function_execution_id: functionExecutionId, - }); - }; + return [ + onlyEvents, + matchEventType('function_executed'), + matchCallbackId(this.callbackId), + ...this.listeners, + ] as Middleware[]; } } /** Helper Functions */ -export function validate(callbackId: string, middleware: CustomFunctionExecuteMiddleware): void { +export function validate( + callbackId: string, + middleware: Middleware>[], +): void { // Ensure callbackId is valid if (typeof callbackId !== 'string') { const errorMsg = 'CustomFunction expects a callback_id as the first argument'; @@ -161,55 +119,40 @@ export function validate(callbackId: string, middleware: CustomFunctionExecuteMi } /** - * `processFunctionMiddleware()` invokes each listener middleware + * Factory for `complete()` utility */ -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 createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn { + const { functionExecutionId } = context; -export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllCustomFunctionMiddlewareArgs { - return VALID_PAYLOAD_TYPES.has(args.payload.type); -} + if (!functionExecutionId) { + const errorMsg = 'No function_execution_id found'; + throw new CustomFunctionCompleteSuccessError(errorMsg); + } -function selectToken(context: Context): string | undefined { - // If attachFunctionToken = false, fallback to botToken or userToken - return context.functionBotAccessToken ? context.functionBotAccessToken : context.botToken || context.userToken; + return (params: Parameters[0] = {}) => + client.functions.completeSuccess({ + outputs: params.outputs || {}, + function_execution_id: functionExecutionId, + }); } /** - * `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()` + * Factory for `fail()` utility + */ +export function createFunctionFail(context: Context, client: WebClient): FunctionFailFn { + 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({ + error, + function_execution_id: functionExecutionId, + }); + }; } diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 90f00d307..633730e09 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -15,6 +15,7 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, + SlackEventMiddlewareArgsOptions, SlackOptionsMiddlewareArgs, SlackShortcutMiddlewareArgs, SlackViewAction, @@ -63,6 +64,13 @@ function isMessageEventArgs(args: AnyMiddlewareArgs): args is SlackEventMiddlewa return isEventArgs(args) && 'message' in args; } +export function isSlackEventMiddlewareArgsOptions< + Options extends SlackEventMiddlewareArgsOptions, + EventMiddlewareArgs extends SlackEventMiddlewareArgs, +>(optionOrListener: Options | Middleware): optionOrListener is Options { + return typeof optionOrListener !== 'function' && 'autoAcknowledge' in optionOrListener; +} + /** * Middleware that filters out any event that isn't an action */ @@ -119,6 +127,16 @@ export const onlyViewActions: Middleware = async (args) => { } }; +/** + * Middleware that auto acknowledges the request received + */ +export const autoAcknowledge: Middleware = async (args) => { + if ('ack' in args && args.ack !== undefined) { + await args.ack(); + } + await args.next(); +}; + /** * Middleware that checks for matches given constraints */ diff --git a/src/types/events/index.ts b/src/types/events/index.ts index 7b485e5fc..5ff16e244 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -1,15 +1,18 @@ import type { SlackEvent } from '@slack/types'; -import type { SayFn, StringIndexed } from '../utilities'; +import type { AckFn, SayFn, StringIndexed } from '../utilities'; + +export type SlackEventMiddlewareArgsOptions = { autoAcknowledge: boolean }; /** * Arguments which listeners and middleware receive to process an event from Slack's Events API. */ -export type SlackEventMiddlewareArgs = { +export type SlackEventMiddlewareArgs< + EventType extends string = string, + Options extends SlackEventMiddlewareArgsOptions = { autoAcknowledge: true }, +> = { payload: EventFromType; event: EventFromType; body: EnvelopedEvent>; - // Add `ack` as undefined for global middleware in TypeScript TODO: but why? spend some time digging into this - ack?: undefined; } & (EventType extends 'message' ? // If this is a message event, add a `message` property { message: EventFromType } @@ -17,7 +20,8 @@ export type SlackEventMiddlewareArgs = { (EventFromType extends { channel: string } | { item: { channel: string } } ? // If this event contains a channel, add a `say` utility function { say: SayFn } - : unknown); + : unknown) & + (Options['autoAcknowledge'] extends true ? unknown : { ack: AckFn }); export interface BaseSlackEvent { type: T; diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 8f2bec71f..99332f442 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -1,21 +1,23 @@ import type { Logger } from '@slack/logger'; import type { WebClient } from '@slack/web-api'; +import type { SlackCustomFunctionMiddlewareArgs } from '../CustomFunction'; import type { SlackActionMiddlewareArgs } from './actions'; import type { SlackCommandMiddlewareArgs } from './command'; -import type { FunctionInputs, SlackEventMiddlewareArgs } from './events'; +import type { FunctionInputs, SlackEventMiddlewareArgs, SlackEventMiddlewareArgsOptions } from './events'; import type { SlackOptionsMiddlewareArgs } from './options'; import type { SlackShortcutMiddlewareArgs } from './shortcuts'; import type { StringIndexed } from './utilities'; import type { SlackViewMiddlewareArgs } from './view'; // TODO: rename this to AnyListenerArgs, and all the constituent types -export type AnyMiddlewareArgs = - | SlackEventMiddlewareArgs +export type AnyMiddlewareArgs = + | SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackCommandMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs - | SlackShortcutMiddlewareArgs; + | SlackShortcutMiddlewareArgs + | SlackCustomFunctionMiddlewareArgs; export interface AllMiddlewareArgs { context: Context & CustomContext; diff --git a/src/types/utilities.ts b/src/types/utilities.ts index c70f5e692..18e615472 100644 --- a/src/types/utilities.ts +++ b/src/types/utilities.ts @@ -1,4 +1,5 @@ import type { ChatPostMessageArguments, ChatPostMessageResponse } from '@slack/web-api'; + // TODO: breaking change: remove, unnecessary abstraction, just use Record directly /** * Extend this interface to build a type that is treated as an open set of properties, where each key is a string. diff --git a/test/types/function.test-d.ts b/test/types/function.test-d.ts new file mode 100644 index 000000000..2d1bb71d4 --- /dev/null +++ b/test/types/function.test-d.ts @@ -0,0 +1,33 @@ +import { expectError, expectNotType, expectType } from 'tsd'; +import App from '../../src/App'; +import type { FunctionCompleteFn, FunctionFailFn } from '../../src/CustomFunction'; +import type { FunctionInputs } from '../../src/types'; +import type { AckFn } from '../../src/types/utilities'; + +const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); + +// By default `function` handlers auto-acknowledge events so `ack` should not be provided/defined +expectError( + app.function('callback', async ({ ack }) => { + expectNotType>(ack); + }), +); + +// For `function` handlers that are auto-acknowledged, `ack` should not be provided/defined +expectError( + app.function('callback', { autoAcknowledge: true }, async ({ ack }) => { + expectNotType>(ack); + }), +); + +// For `function` handlers that are not auto-acknowledged, `ack` should be provided/defined +app.function('callback', { autoAcknowledge: false }, async ({ ack }) => { + expectType>(ack); +}); + +// By default `function` handlers provide/define the proper arguments +app.function('callback', async ({ inputs, complete, fail }) => { + expectType(inputs); + expectType(complete); + expectType(fail); +}); diff --git a/test/unit/App/middleware.spec.ts b/test/unit/App/middleware.spec.ts index 4d87fc203..24ba2383e 100644 --- a/test/unit/App/middleware.spec.ts +++ b/test/unit/App/middleware.spec.ts @@ -10,6 +10,7 @@ import { type Override, createDummyAppMentionEventMiddlewareArgs, createDummyBlockActionEventMiddlewareArgs, + createDummyCustomFunctionMiddlewareArgs, createDummyMessageEventMiddlewareArgs, createDummyReceiverEvent, createDummyViewSubmissionMiddlewareArgs, @@ -762,6 +763,43 @@ describe('App middleware processing', () => { assert.equal(globalClient, clientArg); }); + + it('should use the xwfp token if the request contains one', async () => { + const MockApp = await importApp(); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: noop, + }); + + let clientArg: WebClient | undefined; + app.use(async ({ client }) => { + clientArg = client; + }); + const testData = createDummyCustomFunctionMiddlewareArgs({ options: { autoAcknowledge: false } }); + await fakeReceiver.sendEvent({ ack: testData.ack, body: testData.body }); + + assert.notTypeOf(clientArg, 'undefined'); + assert.equal(clientArg?.token, 'xwfp-valid'); + }); + + it('should not use xwfp token if the request contains one and attachFunctionToken is false', async () => { + const MockApp = await importApp(); + const app = new MockApp({ + receiver: fakeReceiver, + authorize: noop, + attachFunctionToken: false, + }); + + let clientArg: WebClient | undefined; + app.use(async ({ client }) => { + clientArg = client; + }); + const testData = createDummyCustomFunctionMiddlewareArgs({ options: { autoAcknowledge: false } }); + await fakeReceiver.sendEvent({ ack: testData.ack, body: testData.body }); + + assert.notTypeOf(clientArg, 'undefined'); + assert.equal(clientArg?.token, undefined); + }); }); describe('say()', () => { @@ -986,7 +1024,7 @@ describe('App middleware processing', () => { authorize: sinon.fake.resolves(dummyAuthorizationResult), }); app.use(async ({ ack, next }) => { - if (ack) { + if (ack !== noopVoid) { // this should be called even if app.view listeners do not exist await ack(); return; diff --git a/test/unit/App/routing-function.spec.ts b/test/unit/App/routing-function.spec.ts new file mode 100644 index 000000000..86bd92cf4 --- /dev/null +++ b/test/unit/App/routing-function.spec.ts @@ -0,0 +1,111 @@ +import { assert } from 'chai'; +import sinon, { type SinonSpy } from 'sinon'; +import type App from '../../../src/App'; +import { + FakeReceiver, + type Override, + createDummyCustomFunctionMiddlewareArgs, + createFakeLogger, + importApp, + mergeOverrides, + noopMiddleware, + withConversationContext, + withMemoryStore, + withNoopAppMetadata, + withNoopWebClient, +} from '../helpers'; + +function buildOverrides(secondOverrides: Override[]): Override { + return mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ...secondOverrides, + withMemoryStore(sinon.fake()), + withConversationContext(sinon.fake.returns(noopMiddleware)), + ); +} + +describe('App function() routing', () => { + let fakeReceiver: FakeReceiver; + let fakeHandler: SinonSpy; + const fakeLogger = createFakeLogger(); + let dummyAuthorizationResult: { botToken: string; botId: string }; + let MockApp: Awaited>; + let app: App; + + beforeEach(async () => { + fakeLogger.error.reset(); + fakeReceiver = new FakeReceiver(); + fakeHandler = sinon.fake(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + MockApp = await importApp(buildOverrides([])); + app = new MockApp({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + describe('for function executed events', () => { + it('should route a function executed event to a handler registered with `function(string)` that matches the callback ID', async () => { + app.function('my_id', fakeHandler); + const args = createDummyCustomFunctionMiddlewareArgs({ + callbackId: 'my_id', + options: { autoAcknowledge: false }, + }); + await fakeReceiver.sendEvent({ + ack: args.ack, + body: args.body, + }); + sinon.assert.called(fakeHandler); + }); + + it('should route a function executed event to a handler with the proper arguments', async () => { + const testInputs = { test: true }; + const testHandler = sinon.spy(async ({ inputs, complete, fail, client }) => { + assert.equal(inputs, testInputs); + assert.typeOf(complete, 'function'); + assert.typeOf(fail, 'function'); + assert.equal(client.token, 'xwfp-valid'); + }); + app.function('my_id', testHandler); + const args = createDummyCustomFunctionMiddlewareArgs({ + callbackId: 'my_id', + inputs: testInputs, + options: { autoAcknowledge: false }, + }); + await fakeReceiver.sendEvent({ + ack: args.ack, + body: args.body, + }); + sinon.assert.called(testHandler); + }); + + it('should route a function executed event to a handler and auto ack by default', async () => { + app.function('my_id', fakeHandler); + const args = createDummyCustomFunctionMiddlewareArgs({ callbackId: 'my_id' }); + let isAck = false; + await fakeReceiver.sendEvent({ + ack: async () => { + isAck = true; + }, + body: args.body, + }); + sinon.assert.called(fakeHandler); + assert.isTrue(isAck); + }); + + it('should route a function executed event to a handler and NOT auto ack if autoAcknowledge is false', async () => { + app.function('my_id', { autoAcknowledge: false }, fakeHandler); + const args = createDummyCustomFunctionMiddlewareArgs({ callbackId: 'my_id' }); + let isAck = false; + await fakeReceiver.sendEvent({ + ack: async () => { + isAck = true; + }, + body: args.body, + }); + sinon.assert.called(fakeHandler); + assert.isFalse(isAck); + }); + }); +}); diff --git a/test/unit/CustomFunction.spec.ts b/test/unit/CustomFunction.spec.ts index 88ca9a0fb..ee415cc37 100644 --- a/test/unit/CustomFunction.spec.ts +++ b/test/unit/CustomFunction.spec.ts @@ -1,21 +1,17 @@ import { WebClient } from '@slack/web-api'; import { assert } from 'chai'; -import rewiremock from 'rewiremock'; import sinon from 'sinon'; import { - type AllCustomFunctionMiddlewareArgs, CustomFunction, - type CustomFunctionExecuteMiddlewareArgs, - type CustomFunctionMiddleware, type SlackCustomFunctionMiddlewareArgs, + createFunctionComplete, + createFunctionFail, + matchCallbackId, + validate, } from '../../src/CustomFunction'; import { CustomFunctionInitializationError } from '../../src/errors'; -import type { AllMiddlewareArgs, Middleware } from '../../src/types'; -import { type Override, createFakeLogger } from './helpers'; - -async function importCustomFunction(overrides: Override = {}): Promise { - return rewiremock.module(() => import('../../src/CustomFunction'), overrides); -} +import { autoAcknowledge, matchEventType, onlyEvents } from '../../src/middleware/builtin'; +import type { Middleware } from '../../src/types'; const MOCK_FN = async () => {}; const MOCK_FN_2 = async () => {}; @@ -23,65 +19,42 @@ const MOCK_FN_2 = async () => {}; const MOCK_MIDDLEWARE_SINGLE = [MOCK_FN]; const MOCK_MIDDLEWARE_MULTIPLE = [MOCK_FN, MOCK_FN_2]; -describe('CustomFunction class', () => { +describe('CustomFunction', () => { describe('constructor', () => { it('should accept single function as middleware', async () => { - const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_SINGLE, {}); + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_SINGLE, { autoAcknowledge: true }); assert.isNotNull(fn); }); it('should accept multiple functions as middleware', async () => { - const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE, {}); + const fn = new CustomFunction('test_callback_id', MOCK_MIDDLEWARE_MULTIPLE, { autoAcknowledge: true }); assert.isNotNull(fn); }); }); - describe('getMiddleware', () => { - it('should not call next if a function_executed event', async () => { + describe('getListeners', () => { + it('should return an ordered array of listeners used to map function events to handlers', 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); + const fn = new CustomFunction(cbId, MOCK_MIDDLEWARE_SINGLE, { autoAcknowledge: true }); + const listeners = fn.getListeners(); + assert.equal(listeners.length, 5); + assert.equal(listeners[0], onlyEvents); + assert.equal(listeners[1].toString(), matchEventType('function_executed').toString()); + assert.equal(listeners[2].toString(), matchCallbackId(cbId).toString()); + assert.equal(listeners[3], autoAcknowledge); + assert.equal(listeners[4], MOCK_FN); }); - 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); + it('should return a array of listeners without the autoAcknowledge middleware when auto acknowledge is disabled', async () => { + const cbId = 'test_executed_callback_id'; + const fn = new CustomFunction(cbId, MOCK_MIDDLEWARE_SINGLE, { autoAcknowledge: false }); + const listeners = fn.getListeners(); + assert.isFalse(listeners.includes(autoAcknowledge)); }); }); 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); @@ -91,10 +64,8 @@ describe('CustomFunction class', () => { }); 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; + // intentionally casting to Middleware[] to trigger failure + const badConfig = '' as unknown as Middleware[]; const validationFn = () => validate('callback_id', badConfig); const expectedMsg = 'CustomFunction expects a function or array of functions as the second argument'; @@ -102,10 +73,11 @@ describe('CustomFunction class', () => { }); 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; + // intentionally casting to Middleware[] to trigger failure + const badMiddleware = [ + async () => {}, + 'not-a-function', + ] as unknown as Middleware[]; const validationFn = () => validate('callback_id', badMiddleware); const expectedMsg = 'All CustomFunction middleware must be functions'; @@ -113,66 +85,19 @@ describe('CustomFunction class', () => { }); }); - 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, - ); + const complete = 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); + createFunctionComplete({ isEnterpriseInstall: false }, client); }); }); }); @@ -181,119 +106,16 @@ describe('CustomFunction class', () => { it('fail should call functions.completeError', async () => { const client = new WebClient('sometoken'); const completeMock = sinon.stub(client.functions, 'completeError').resolves(); - const complete = CustomFunction.createFunctionFail( - { isEnterpriseInstall: false, functionExecutionId: 'Fx1234' }, - client, - ); + const complete = 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); + 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(), - next: () => Promise.resolve(), - payload: { - function: func, - inputs: { message: 'test123', recipient: 'U012345' }, - ...base, - }, - }; -} - -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/test/unit/helpers/app.ts b/test/unit/helpers/app.ts index 0c11335db..be8ed0a10 100644 --- a/test/unit/helpers/app.ts +++ b/test/unit/helpers/app.ts @@ -58,7 +58,13 @@ export function withNoopWebClient(authTestResponse?: AuthTestResponse): Override test: sinon.fake.resolves(authTestResponse), }; } - : class {}, + : class { + public token?: string; + + public constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } + }, }, }; } diff --git a/test/unit/helpers/events.ts b/test/unit/helpers/events.ts index 9d612024d..d632b6539 100644 --- a/test/unit/helpers/events.ts +++ b/test/unit/helpers/events.ts @@ -17,6 +17,7 @@ import type { AssistantThreadStartedMiddlewareArgs, AssistantUserMessageMiddlewareArgs, } from '../../../src/Assistant'; +import type { SlackCustomFunctionMiddlewareArgs } from '../../../src/CustomFunction'; import type { AckFn, AllMiddlewareArgs, @@ -35,6 +36,7 @@ import type { SlackActionMiddlewareArgs, SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, + SlackEventMiddlewareArgsOptions, SlackOptionsMiddlewareArgs, SlackShortcutMiddlewareArgs, SlackViewMiddlewareArgs, @@ -208,6 +210,7 @@ export function createDummyAppMentionEventMiddlewareArgs( say, }; } + function enrichDummyAssistantMiddlewareArgs() { return { getThreadContext: sinon.spy(), @@ -272,6 +275,7 @@ export function createDummyAssistantUserMessageEventMiddlewareArgs( ...enrichDummyAssistantMiddlewareArgs(), }; } + interface DummyCommandOverride { command?: string; slashCommand?: SlashCommand; @@ -340,6 +344,89 @@ export function createDummyBlockActionEventMiddlewareArgs( }; } +export function createDummyCustomFunctionMiddlewareArgs< + Options extends SlackEventMiddlewareArgsOptions = { autoAcknowledge: true }, +>( + data: { + callbackId?: string; + inputs?: Record; + options?: Options; + } = { callbackId: 'reverse', inputs: { stringToReverse: 'hello' }, options: { autoAcknowledge: true } as Options }, +): SlackCustomFunctionMiddlewareArgs { + data.callbackId = data.callbackId || 'reverse'; + data.inputs = data.inputs ? data.inputs : { stringToReverse: 'hello' }; + data.options = data.options ? data.options : ({ autoAcknowledge: true } as Options); + const testFunction = { + id: 'Fn111', + callback_id: data.callbackId, + title: data.callbackId, + description: 'Takes a string and reverses it', + type: 'app', + input_parameters: [ + { + type: 'string', + name: 'stringToReverse', + description: 'The string to reverse', + title: 'String To Reverse', + is_required: true, + }, + ], + output_parameters: [ + { + type: 'string', + name: 'reverseString', + description: 'The string in reverse', + title: 'Reverse String', + is_required: true, + }, + ], + app_id: 'A111', + date_updated: 1659054991, + date_deleted: 0, + date_created: 1725987754, + }; + + const event = { + type: 'function_executed', + function: testFunction, + inputs: data.inputs, + function_execution_id: 'Fx111', + workflow_execution_id: 'Wf111', + event_ts: '1659055013.509853', + bot_access_token: 'xwfp-valid', + } as const; + + const body = { + token: 'verification_token', + team_id: 'T111', + api_app_id: 'A111', + event, + event_id: 'Ev111', + event_time: 1659055013, + type: 'event_callback', + } as const; + + if (data.options.autoAcknowledge) { + return { + body, + complete: () => Promise.resolve({ ok: true }), + event, + fail: () => Promise.resolve({ ok: true }), + inputs: data.inputs, + payload: event, + } as SlackCustomFunctionMiddlewareArgs; + } + return { + ack: () => Promise.resolve(), + body, + complete: () => Promise.resolve({ ok: true }), + event, + fail: () => Promise.resolve({ ok: true }), + inputs: data.inputs, + payload: event, + }; +} + interface DummyBlockSuggestionOverride { action_id?: string; block_id?: string; diff --git a/test/unit/middleware/builtin.spec.ts b/test/unit/middleware/builtin.spec.ts index 1567f9ffc..fd8b35643 100644 --- a/test/unit/middleware/builtin.spec.ts +++ b/test/unit/middleware/builtin.spec.ts @@ -2,9 +2,15 @@ import { assert } from 'chai'; import rewiremock from 'rewiremock'; import sinon from 'sinon'; +import { expectType } from 'tsd'; import { ErrorCode } from '../../../src/errors'; +import { isSlackEventMiddlewareArgsOptions } from '../../../src/middleware/builtin'; // import { matchCommandName, matchEventType, onlyCommands, onlyEvents, subtype } from '../../../src/middleware/builtin'; -import type { Context, /* NextFn, */ SlackEventMiddlewareArgs } from '../../../src/types'; +import type { + Context, + /* NextFn, */ SlackEventMiddlewareArgs, + SlackEventMiddlewareArgsOptions, +} from '../../../src/types'; import { type Override, createDummyAppHomeOpenedEventMiddlewareArgs, @@ -411,4 +417,25 @@ describe('Built-in global middleware', () => { }); }); }); + + describe(isSlackEventMiddlewareArgsOptions.name, () => { + it('should return true if object is SlackEventMiddlewareArgsOptions', async () => { + const actual = isSlackEventMiddlewareArgsOptions({ autoAcknowledge: true }); + assert.isTrue(actual); + }); + + it('should narrow proper type if object is SlackEventMiddlewareArgsOptions', async () => { + const option = { autoAcknowledge: true }; + if (isSlackEventMiddlewareArgsOptions({ autoAcknowledge: true })) { + expectType(option); + } else { + assert.fail(`${option} should be of type SlackEventMiddlewareArgsOption`); + } + }); + + it('should return false if object is Middleware', async () => { + const actual = isSlackEventMiddlewareArgsOptions(async () => {}); + assert.isFalse(actual); + }); + }); });