From ac525e9d01653eb949521968dc6b43ddcd6752e6 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Fri, 1 Apr 2022 17:21:15 -0700 Subject: [PATCH 01/16] Add run script hook --- src/cli/run.ts | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 src/cli/run.ts diff --git a/src/cli/run.ts b/src/cli/run.ts new file mode 100644 index 000000000..5dbd5f6bf --- /dev/null +++ b/src/cli/run.ts @@ -0,0 +1,35 @@ +import { spawn } from 'child_process'; +import path from 'path'; + +// Run script hook verifies that requirements for running an App in +// in developerMode (via Socket Mode) are met +(function _(cwd: string) { + // TODO - Format so that its less miss-able in output + process.stdout.write('Preparing local run in developer mode (Socket Mode)\n'); + // Check required local run tokens + validate(); + + // Kick off a subprocess to run the app in development mode + const app = spawn('node', [`${path.resolve(cwd, 'app.js')}`]); + app.stdout.setEncoding('utf-8'); + // TODO - Is there a way to configure this in spawn invocation + app.stdout.on('data', (data) => { + process.stdout.write(data); + }); + app.stderr.on('data', (data) => { + process.stderr.write(data); + }); + + app.on('close', (code) => { + process.stdout.write(`bolt-app local run exited with code ${code}`); + }); +}(process.cwd())); + +function validate() { + if (!process.env.SLACK_CLI_XOXB) { + throw new Error('Missing local run bot token. Please see slack-cli maintainers to troubleshoot.'); + } + if (!process.env.SLACK_CLI_XAPP) { + throw new Error('Missing local run app token. Please see slack-cli maintainers to troubleshoot'); + } +} From 5e8d03eb0188a60dccc4238a594e41d03a19387b Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 2 Aug 2022 21:42:22 -0700 Subject: [PATCH 02/16] Update Function (#1536) --- src/SlackFunction.ts | 8 ++++++++ src/types/events/base-events.ts | 4 ++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index 968d5a450..e9a03ac41 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -24,8 +24,16 @@ export type AllSlackFunctionExecutedMiddlewareArgs = SlackFunctionExecutedMiddle * A Function is a deterministic machine with * specified outputs given specific inputs. * -- +<<<<<<< HEAD * You configure a Function's callback_id, inputs, and outputs * in your project's manifest file (json or js). +======= + * You configure a Function's title, inputs, and outputs + * in your project's manifest file (json or js). If your project contains any + * functions via app.function, it must have a corresponding + * manifest entry or App will throw an error when attempting to + * initialize. +>>>>>>> 1c37f9b (Update Function (#1536)) * -- * Slack will take care of providing inputs to your function * via a function_execution event. Bolt handles delivering those diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index 64b659d2b..83b7b4bd5 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -433,9 +433,9 @@ export interface FunctionParams { is_required?: boolean, } -export interface FunctionInputValues { +export type FunctionInputValues = { [key: string]: unknown; -} +}; export type FunctionOutputValues = FunctionInputValues; From 07f3a4f83133c8564188687b4e985a73dd607283 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Thu, 18 Aug 2022 11:05:12 -0700 Subject: [PATCH 03/16] merge --- src/SlackFunction.ts | 17 ++++------------- src/types/events/base-events.ts | 4 ++-- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index e9a03ac41..02543824d 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -21,30 +21,21 @@ export interface ErrorFn { export type AllSlackFunctionExecutedMiddlewareArgs = SlackFunctionExecutedMiddlewareArgs & AllMiddlewareArgs; /** - * A Function is a deterministic machine with + * A SlackFunction is a deterministic machine with * specified outputs given specific inputs. * -- -<<<<<<< HEAD - * You configure a Function's callback_id, inputs, and outputs + * Configure a SlackFunction's callback_id, inputs, and outputs * in your project's manifest file (json or js). -======= - * You configure a Function's title, inputs, and outputs - * in your project's manifest file (json or js). If your project contains any - * functions via app.function, it must have a corresponding - * manifest entry or App will throw an error when attempting to - * initialize. ->>>>>>> 1c37f9b (Update Function (#1536)) * -- * Slack will take care of providing inputs to your function * via a function_execution event. Bolt handles delivering those * to your function in the way you can expect of regular events, * messages, shortcuts commands, etc. * -- - * When initiating an instance of Function below, you supply the + * When initiating an instance of SlackFunction below, you supply the * callback you want to process the supplied inputs and what logical * conditions determine success or failure in your use case. - * Call the supplied utilities completeSuccess with your specified - * outputs or completeError. + * Call the supplied utility complete with either outputs or an error * */ export class SlackFunction { /** diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index 83b7b4bd5..64b659d2b 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -433,9 +433,9 @@ export interface FunctionParams { is_required?: boolean, } -export type FunctionInputValues = { +export interface FunctionInputValues { [key: string]: unknown; -}; +} export type FunctionOutputValues = FunctionInputValues; From bbddd25bf1c3cf6031752c66efac0f2fcd0cd96f Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Thu, 18 Aug 2022 14:20:40 -0700 Subject: [PATCH 04/16] Override token for function and function interactivity events --- src/App.ts | 73 +++++++++++++++++++++++++++------------ src/middleware/process.ts | 1 + src/types/middleware.ts | 14 ++++++++ 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/App.ts b/src/App.ts index cb3f45151..859c2b7a4 100644 --- a/src/App.ts +++ b/src/App.ts @@ -512,17 +512,6 @@ export default class App return this; } - /** - * Register WorkflowStep middleware - * - * @param workflowStep global workflow step middleware function - */ - public step(workflowStep: WorkflowStep): this { - const m = workflowStep.getMiddleware(); - this.middleware.push(m); - return this; - } - /** * Convenience method to call start on the receiver * @@ -548,7 +537,20 @@ export default class App } /** - * Register subcription middleware + * Register WorkflowStep middleware + * + * Not to be confused with next-gen platform Workflows + Functions + * + * @param workflowStep global workflow step middleware function + */ + public step(workflowStep: WorkflowStep): this { + const m = workflowStep.getMiddleware(); + this.middleware.push(m); + return this; + } + + /** + * Process a subscription event * * @param listeners middleware that process and react to subscription payloads */ @@ -564,6 +566,9 @@ export default class App return this; } + /** + * Register an event listener + */ public event< EventType extends string = string, MiddlewareCustomContext extends StringIndexed = StringIndexed, @@ -610,21 +615,27 @@ export default class App } /** - * Register listeners that process and react to a function execution event - * @param title the name of the fn as defined in manifest file - * must match the function defined in manifest file - * @param fn a single function to register + * Register a Slack Function handler + * + * and other function-scoped + * interactivity handlers + * (block_actions, view interaction payloads) + * + * + * @param callbackId the id of the function as defined in manifest + * + * @param fn a function to register * */ - public function(title: string, fn: Middleware): this { - // TODO: Support for multiple function listeners - const slackFn = new SlackFunction(title, fn); + public function(callbackId: string, fn: Middleware): this { + const slackFn = new SlackFunction(callbackId, fn); const m = slackFn.getMiddleware(); this.middleware.push(m); return this; } /** - * + * Process a message event + * * @param listeners Middlewares that process and react to a message event */ public message< @@ -700,6 +711,9 @@ export default class App ] as Middleware[]); } + /** + * Process a shortcut event + */ public shortcut< Shortcut extends SlackShortcut = SlackShortcut, MiddlewareCustomContext extends StringIndexed = StringIndexed, @@ -745,6 +759,11 @@ export default class App ] as Middleware[]); } + /** + * Process a block_action event + * https://api.slack.com/reference/interaction-payloads/block-actions + * + */ // NOTE: this is what's called a convenience generic, so that types flow more easily without casting. // https://web.archive.org/web/20210629110615/https://basarat.gitbook.io/typescript/type-system/generics#motivation-and-samples public action< @@ -923,6 +942,7 @@ export default class App const isEnterpriseInstall = isBodyWithTypeEnterpriseInstall(bodyArg, type); const source = buildSource(type, conversationId, bodyArg, isEnterpriseInstall); + // Get the result of any custom authorize provided let authorizeResult: AuthorizeResult; if (type === IncomingEventType.Event && isEventTypeToSkipAuthorize(event.body.event.type)) { authorizeResult = { @@ -967,13 +987,20 @@ export default class App } } + // check for a bot access token + const botAccessToken = event.body.event.bot_access_token; + + // Declare the event context const context: Context = { ...authorizeResult, ...event.customProperties, + botAccessToken, isEnterpriseInstall, retryNum: event.retryNum, retryReason: event.retryReason, }; + console.log("DEBUG***: PROCESSEVENT: EVENT BODY", event.body); + console.log("DEBUG***: PROCESSEVENT: CONTEXT", context); // Factory for say() utility const createSay = (channelId: string): SayFn => { @@ -1589,9 +1616,11 @@ function isBlockActionOrInteractiveMessageBody( return (body as SlackActionMiddlewareArgs['body']).actions !== undefined; } -// Returns either a bot token or a user token for client, say() +/** + * Returns a bot token, bot access token or user token for client, say() + * */ function selectToken(context: Context): string | undefined { - return context.botToken !== undefined ? context.botToken : context.userToken; + return context.botAccessToken ?? context.botToken ?? context.userToken; } function buildRespondFn( diff --git a/src/middleware/process.ts b/src/middleware/process.ts index 7934e01f4..ac24e0fd9 100644 --- a/src/middleware/process.ts +++ b/src/middleware/process.ts @@ -10,6 +10,7 @@ export default async function processMiddleware( logger: Logger, last: () => Promise, ): Promise { + let lastCalledMiddlewareIndex = -1; async function invokeMiddleware(toCallMiddlewareIndex: number): ReturnType> { if (lastCalledMiddlewareIndex >= toCallMiddlewareIndex) { diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 03eaddfa5..5c7b91d79 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -44,6 +44,20 @@ export interface Context extends StringIndexed { * This value can be used by `say` (preferred over userToken), */ botToken?: string; + /** + * A bot access token, which starts with `xwfp-`. + * This is a just-in-time token. + * + * A bot access token is a short-lived (JIT) token + * sent along in any event payload associated with + * for one of your apps custom Slack Functions. + * + * When present, client should use this token over + * any other regular xoxb or xoxb. + * + * Read about custom Slack Functions @ https://api.slack.com/future/functions + * */ + botAccessToken?: string; /** * A bot token, which starts with `xoxp-`. * This value can be used by `say` (overridden by botToken), From aa25971be71302d2bc410f4dc4e70cc86c68f865 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Thu, 18 Aug 2022 19:03:51 -0700 Subject: [PATCH 05/16] completeSuccess() and completeError() --> complete() --- src/App.ts | 6 ++--- src/SlackFunction.ts | 64 ++++++++++++++++++++------------------------ 2 files changed, 31 insertions(+), 39 deletions(-) diff --git a/src/App.ts b/src/App.ts index 859c2b7a4..de89e7e7a 100644 --- a/src/App.ts +++ b/src/App.ts @@ -987,8 +987,8 @@ export default class App } } - // check for a bot access token - const botAccessToken = event.body.event.bot_access_token; + // set bot access token if it exists + let botAccessToken = body.bot_access_token ?? body.event.bot_access_token; // Declare the event context const context: Context = { @@ -999,8 +999,6 @@ export default class App retryNum: event.retryNum, retryReason: event.retryReason, }; - console.log("DEBUG***: PROCESSEVENT: EVENT BODY", event.body); - console.log("DEBUG***: PROCESSEVENT: CONTEXT", context); // Factory for say() utility const createSay = (channelId: string): SayFn => { diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index 02543824d..bd48b8ed0 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -7,15 +7,17 @@ import { /* Types */ export interface SlackFunctionExecutedMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { - completeSuccess: SuccessFn; - completeError: ErrorFn; + complete: completeFunction } -export interface SuccessFn { - (outputs: Record): Promise +export interface completeFunction { + (args: completeFunctionArgs): Promise } -export interface ErrorFn { - (error: string): Promise + +export type completeFunctionArgs = { + // outputs are set by developer in the manifest file + outputs?: Record, + error?: string } export type AllSlackFunctionExecutedMiddlewareArgs = SlackFunctionExecutedMiddlewareArgs & AllMiddlewareArgs; @@ -91,37 +93,29 @@ function isFunctionExecutedEvent(args: AnyMiddlewareArgs): boolean { function prepareFnArgs(args: AnyMiddlewareArgs & AllMiddlewareArgs): AllSlackFunctionExecutedMiddlewareArgs { const { next: _next, ...subArgs } = args; const preparedArgs: any = { ...subArgs }; - preparedArgs.completeSuccess = createCompleteSuccess(preparedArgs); - preparedArgs.completeError = createCompleteError(preparedArgs); + preparedArgs.complete = createComplete(preparedArgs); return preparedArgs; } -/** - * Returns a utility function that is used to call the functions.completeSuccess - * API endpoint with the provided outputs -*/ -function createCompleteSuccess(args: any): SuccessFn { - const { client, event } = args; - const { function_execution_id } = event; - // TODO: Support client.functions.completeSuccess in node-slack-sdk - return (outputs: any) => client.apiCall('functions.completeSuccess', { - outputs, - function_execution_id, - }); -} -/** - * Returns a utility function that is used to call the functions.completeError - * API endpoint with the provided outputs -*/ -function createCompleteError(args: any): ErrorFn { +function createComplete(args: any): completeFunction { const { client, event } = args; const { function_execution_id } = event; - // TODO: Support client.functions.completeError in node-slack-sdk - // TODO: Review whether to use installed app's bot token to make the api call - // in the future it is possible that the event payload itself will contain - // workspace token which should be used instead of the app token - return (error: string) => client.apiCall('functions.completeError', { - error, - function_execution_id, - }); -} + + return ({ outputs, error }: completeFunctionArgs) => { + if (outputs && error) { + throw new Error("Cannot complete a function with both outputs and error message"); + } + // if user has supplied outputs OR has supplied neither outputs nor error + if (outputs !== undefined || (outputs === undefined && error === undefined)) { + return client.apiCall('functions.completeSuccess', { + outputs, + function_execution_id + }) + } else if (error !== undefined) { + return client.apiCall('functions.completeError', { + error, + function_execution_id + }) + } + } +} \ No newline at end of file From 668e3888d75e20113d1ac230177d732d30537845 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Thu, 18 Aug 2022 19:39:54 -0700 Subject: [PATCH 06/16] Extend actions types to include function context and function interactivity context --- src/types/actions/block-action.ts | 6 ++++++ src/types/actions/dialog-action.ts | 4 ++++ src/types/functions/index.ts | 29 +++++++++++++++++++++++++++++ 3 files changed, 39 insertions(+) create mode 100644 src/types/functions/index.ts diff --git a/src/types/actions/block-action.ts b/src/types/actions/block-action.ts index be296418e..3c05ce576 100644 --- a/src/types/actions/block-action.ts +++ b/src/types/actions/block-action.ts @@ -1,6 +1,7 @@ import { PlainTextElement, Confirmation, Option } from '@slack/types'; import { StringIndexed } from '../helpers'; import { ViewOutput, ViewStateValue } from '../view'; +import { FunctionExecutionContext, FunctionInteractivityContext } from '../functions'; /** * All known actions from in Slack's interactive elements @@ -259,6 +260,11 @@ export interface BlockAction, + function: { + id: string, + callback_id: string, + title: string, + description: string, + type: string, + input_parameters: [], + output_parameters: [], + app_id: string, + date_updated: number, + } +} + +/** +* FunctionInteractivityContext +*/ +export interface FunctionInteractivityContext { + interactor: { + secret: string, + id: string, + } + interactivity_pointer: string, +} From b6501ef2e815302522feb5fa09f9c6a75d045dc9 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Fri, 19 Aug 2022 15:15:22 -0700 Subject: [PATCH 07/16] Extend app.function to register action + view handlers --- package.json | 2 +- src/App-slack-function.spec.ts | 100 ++++++ src/App.ts | 82 +++-- src/Manifest.ts | 2 + src/SlackFunction.spec.ts | 369 ++++++++++++++++++++ src/SlackFunction.ts | 541 +++++++++++++++++++++++++---- src/cli/get-manifest.js | 30 +- src/cli/hook-utils/manifest.js | 36 +- src/errors.ts | 16 + src/middleware/builtin.ts | 1 + src/middleware/process.ts | 1 - src/types/actions/block-action.ts | 9 +- src/types/actions/dialog-action.ts | 4 +- src/types/functions/index.ts | 22 +- src/types/middleware.ts | 14 +- src/types/view/index.ts | 6 +- tsconfig.json | 2 +- 17 files changed, 1073 insertions(+), 164 deletions(-) create mode 100644 src/App-slack-function.spec.ts create mode 100644 src/SlackFunction.spec.ts diff --git a/package.json b/package.json index ff0867ac0..33f0bcff4 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "url": "https://github.com/slackapi/bolt-js/issues" }, "dependencies": { - "@slack/deno-slack-sdk": "^0.1.0", + "@slack/deno-slack-sdk": "^0.2.0", "@slack/logger": "^3.0.0", "@slack/oauth": "^2.5.1", "@slack/socket-mode": "^1.3.0", diff --git a/src/App-slack-function.spec.ts b/src/App-slack-function.spec.ts new file mode 100644 index 000000000..0d9feefbc --- /dev/null +++ b/src/App-slack-function.spec.ts @@ -0,0 +1,100 @@ +import 'mocha'; +import sinon from 'sinon'; +import { assert } from 'chai'; +import rewiremock from 'rewiremock'; +import { Override, mergeOverrides } from './test-helpers'; +import { + Receiver, + ReceiverEvent, +} from './types'; +import App from './App'; +import importSlackFunctionModule from './SlackFunction.spec'; + +// Fakes +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); + } +} + +describe('App SlackFunction middleware', () => { + let fakeReceiver: FakeReceiver; + let dummyAuthorizationResult: { botToken: string; botId: string }; + + beforeEach(() => { + fakeReceiver = new FakeReceiver(); + dummyAuthorizationResult = { botToken: '', botId: '' }; + }); + + let app: App; + + beforeEach(async () => { + const MockAppNoOverrides = await importApp(); + app = new MockAppNoOverrides({ + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + }); + + it('should add a middleware for each SlackFunction passed to app.function', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const slackFn = new SlackFunction(mockFunctionCallbackId, () => new Promise((resolve) => resolve())); + + const { middleware } = (app as any); + + assert.equal(middleware.length, 2); + + app.function(slackFn); + + assert.equal(middleware.length, 3); + }); +}); + +/* Testing Harness */ + +// Loading the system under test using overrides +async function importApp( + overrides: Override = mergeOverrides( + withNoopAppMetadata(), + withNoopWebClient(), + ), +): Promise { + return (await rewiremock.module(() => import('./App'), overrides)).default; +} + +// Composable overrides +function withNoopWebClient(): Override { + return { + '@slack/web-api': { + WebClient: class {}, + }, + }; +} + +function withNoopAppMetadata(): Override { + return { + '@slack/web-api': { + addAppMetadata: sinon.fake(), + }, + }; +} + +export default function withMockValidManifestUtil(functionCallbackId: string): Override { + const mockManifestOutput = JSON.parse(`{"functions": {"${functionCallbackId}": {}}}`); + return { + './cli/hook-utils/manifest': { + getManifestData: () => mockManifestOutput, + }, + }; +} diff --git a/src/App.ts b/src/App.ts index de89e7e7a..69309fa8e 100644 --- a/src/App.ts +++ b/src/App.ts @@ -23,7 +23,10 @@ import processMiddleware from './middleware/process'; import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; import { WorkflowStep } from './WorkflowStep'; import { Subscription, SubscriptionOptions } from './Subscription'; -import { SlackFunction } from './SlackFunction'; +import { + CompleteFunction, + SlackFunction, +} from './SlackFunction'; import { Middleware, AnyMiddlewareArgs, @@ -538,17 +541,17 @@ export default class App /** * Register WorkflowStep middleware - * + * * Not to be confused with next-gen platform Workflows + Functions * * @param workflowStep global workflow step middleware function */ - public step(workflowStep: WorkflowStep): this { + public step(workflowStep: WorkflowStep): this { const m = workflowStep.getMiddleware(); this.middleware.push(m); return this; } - + /** * Process a subscription event * @@ -615,27 +618,22 @@ export default class App } /** - * Register a Slack Function handler - * - * and other function-scoped - * interactivity handlers - * (block_actions, view interaction payloads) - * - * + * Register a Slack Function + * * @param callbackId the id of the function as defined in manifest - * - * @param fn a function to register + * + * @param slackFn a main function to register + * * */ - public function(callbackId: string, fn: Middleware): this { - const slackFn = new SlackFunction(callbackId, fn); + + public function(slackFn: SlackFunction): void { const m = slackFn.getMiddleware(); this.middleware.push(m); - return this; } /** - * Process a message event - * + * Process a message event + * * @param listeners Middlewares that process and react to a message event */ public message< @@ -711,7 +709,7 @@ export default class App ] as Middleware[]); } - /** + /** * Process a shortcut event */ public shortcut< @@ -759,10 +757,10 @@ export default class App ] as Middleware[]); } - /** + /** * Process a block_action event * https://api.slack.com/reference/interaction-payloads/block-actions - * + * */ // NOTE: this is what's called a convenience generic, so that types flow more easily without casting. // https://web.archive.org/web/20210629110615/https://basarat.gitbook.io/typescript/type-system/generics#motivation-and-samples @@ -987,8 +985,15 @@ export default class App } } - // set bot access token if it exists - let botAccessToken = body.bot_access_token ?? body.event.bot_access_token; + /** + * Set the Bot Access Token if it exists in event payload + * Sometimes the bot_access_token is located in the event + * Sometimes it is located directly in the body. + */ + let botAccessToken = body.bot_access_token; + if (botAccessToken === undefined && 'event' in body) { + botAccessToken = body.event.bot_access_token; + } // Declare the event context const context: Context = { @@ -1047,17 +1052,7 @@ export default class App break; } - // NOTE: the following doesn't work because... distributive? - // const listenerArgs: Partial = { - const listenerArgs: Pick & { - /** Say function might be set below */ - say?: SayFn; - /** Respond function might be set below */ - respond?: RespondFn; - /** Ack function might be set below */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ack?: AckFn; - } = { + const listenerArgs: ListenerArgs = { body: bodyArg, payload, }; @@ -1108,7 +1103,7 @@ export default class App await ack(); } - // Get the client arg + // Get or create the client let { client } = this; const token = selectToken(context); @@ -1614,11 +1609,11 @@ function isBlockActionOrInteractiveMessageBody( return (body as SlackActionMiddlewareArgs['body']).actions !== undefined; } -/** +/** * Returns a bot token, bot access token or user token for client, say() * */ function selectToken(context: Context): string | undefined { - return context.botAccessToken ?? context.botToken ?? context.userToken; + return context.botAccessToken ?? context.botToken ?? context.userToken; } function buildRespondFn( @@ -1642,3 +1637,16 @@ function isEventTypeToSkipAuthorize(eventType: string) { // Instrumentation // Don't change the position of the following code addAppMetadata({ name: packageJson.name, version: packageJson.version }); + +// types +export type ListenerArgs = Pick & { + /** Say function might be set below */ + say?: SayFn; + /** Respond function might be set below */ + respond?: RespondFn; + /** Ack function might be set below */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ack?: AckFn; + /* Complete might be set below */ + complete?: CompleteFunction; +}; diff --git a/src/Manifest.ts b/src/Manifest.ts index 75eca5605..4fc13f1e3 100644 --- a/src/Manifest.ts +++ b/src/Manifest.ts @@ -7,6 +7,7 @@ import { Schema, ManifestSchema, DefineOAuth2Provider, + DefineDatastore, } from '@slack/deno-slack-sdk'; export const Manifest = (definition: SlackManifestType): ManifestSchema => { @@ -22,6 +23,7 @@ export { Schema, SlackManifest, DefineType, + DefineDatastore, }; export type { diff --git a/src/SlackFunction.spec.ts b/src/SlackFunction.spec.ts new file mode 100644 index 000000000..0e9ef2b8f --- /dev/null +++ b/src/SlackFunction.spec.ts @@ -0,0 +1,369 @@ +import 'mocha'; +import * as assertNode from 'node:assert/strict'; +import { assert } from 'chai'; +import { expectType } from 'tsd'; +import rewiremock from 'rewiremock'; + +import sinon from 'sinon'; +import { WebClient } from '@slack/web-api'; +import { + hasCallbackId, + hasHandler, + isFunctionExecutedEvent, + isFunctionInteractivityEvent, + passConstraint, +} from './SlackFunction'; +import withMockValidManifestUtil from './App-slack-function.spec'; +import { Override } from './test-helpers'; + +import { + AllMiddlewareArgs, + AnyMiddlewareArgs, + Middleware, + SlackEventMiddlewareArgs, +} from './types'; + +import { FunctionExecutionContext } from './types/functions'; +import { SlackFunctionInitializationError } from './errors'; +import { ActionConstraints, ViewConstraints } from './App'; + +export default async function importSlackFunctionModule(overrides: Override = {}): Promise { + return rewiremock.module(() => import('./SlackFunction'), overrides); +} + +describe('SlackFunction module', () => { + describe('SlackFunction class', () => { + describe('app.function.action() adds a handler to interactivity handlers', () => { + it('should not error when valid handler constraints supplied', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const goodConstraints: ActionConstraints = { + action_id: '', + }; + const shouldNotThrow = () => testFunc.action(goodConstraints, async () => {}); + assert.doesNotThrow(shouldNotThrow, SlackFunctionInitializationError); + }); + it('should error when invalid handler constraints supplied', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const badConstraints = { + bad_id: '', + action_id: '', + } as ActionConstraints; + const shouldThrow = () => testFunc.action(badConstraints, async () => {}); + assert.throws(shouldThrow, SlackFunctionInitializationError); + }); + it('should return the instance of slackfunction', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const goodConstraints: ActionConstraints = { + action_id: '', + }; + const mockHandler = async () => {}; + // expect that the return value of action is a Slack function + assert.instanceOf(testFunc.action(goodConstraints, mockHandler), SlackFunction); + // chained valid handlers should not error + const shouldNotThrow = () => testFunc.action(goodConstraints, mockHandler).action(goodConstraints, mockHandler); + assert.doesNotThrow(shouldNotThrow, SlackFunctionInitializationError); + }); + }); + describe('app.function.view() adds a handler to interactivity handlers', () => { + it('should not error when valid view constraints supplied', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const goodConstraints: ViewConstraints = { + type: 'view_submission', + }; + const shouldNotThrow = () => testFunc.view(goodConstraints, async () => {}); + assert.doesNotThrow(shouldNotThrow, SlackFunctionInitializationError); + }); + it('should error when invalid handler constraints supplied', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const badConstraints = { + bad_id: 'view_submission', + } as ViewConstraints; + const shouldThrow = () => testFunc.view(badConstraints, async () => {}); + assert.throws(shouldThrow, SlackFunctionInitializationError); + }); + it('should return the instance of SlackFunction', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const goodConstraints: ViewConstraints = { + callback_id: '', + }; + const mockHandler = async () => {}; + // expect that the return value of view is a Slack function + assert.instanceOf(testFunc.view(goodConstraints, mockHandler), SlackFunction); + // chained valid handlers should not error + const shouldNotThrow = () => testFunc.view(goodConstraints, mockHandler).view(goodConstraints, mockHandler); + assert.doesNotThrow(shouldNotThrow, SlackFunctionInitializationError); + }); + }); + describe('getMiddleware()', () => { + it('it returns a middleware', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const returnVal = testFunc.getMiddleware(); + assert.isDefined(returnVal); + expectType>(returnVal); + }); + }); + describe('runHandler()', () => { + it('should call the handler', async () => { + // set up the slack function + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const spyHandler = sinon.spy((async () => {}) as unknown as Middleware); + const testFunc = new SlackFunction(mockFunctionCallbackId, spyHandler); + + // set up event args + const fakeArgs = { + next: () => {}, + payload: { + function_execution_id: '1234', + }, + body: {}, + client: {} as WebClient, + } as AnyMiddlewareArgs & AllMiddlewareArgs; + + // ensure handler is called + await testFunc.runHandler(fakeArgs); + assert(spyHandler.called); + }); + }); + describe('runInteractivityHandlers', () => { + it('should execute all provided callbacks', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const goodConstraints: ActionConstraints = { + action_id: 'my-action', + }; + const mockHandler = async () => Promise.resolve(); + const spy = sinon.spy(mockHandler); + const spy2 = sinon.spy(mockHandler); + // add an action handlers + testFunc.action(goodConstraints, spy).action(goodConstraints, spy2); + + // set up event args + const fakeArgs = { + next: () => {}, + payload: { + action_id: 'my-action', + }, + body: { + function_data: { + execution_id: 'asdasdas', + }, + }, + client: {} as WebClient, + } as unknown as AnyMiddlewareArgs & AllMiddlewareArgs; + + // ensure handlers are both called + + await testFunc.runInteractivityHandlers(fakeArgs); + assert(spy.calledOnce); + assert(spy2.calledOnce); + }); + it('should error if a promise rejects', async () => { + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const testFunc = new SlackFunction(mockFunctionCallbackId, async () => {}); + const action_id = 'my-action'; + const goodConstraints: ActionConstraints = { + action_id, + }; + const mockHandler = async () => Promise.reject(); + const spy = sinon.spy(mockHandler); + // add an action handlers + testFunc.action(goodConstraints, spy); + + // set up event args + const fakeArgs = { + next: () => {}, + payload: { + action_id, + }, + body: { + function_data: { + execution_id: 'asdasdas', + }, + }, + client: {} as WebClient, + } as unknown as AnyMiddlewareArgs & AllMiddlewareArgs; + + // ensure handlers are not + const shouldReject = async () => testFunc.runInteractivityHandlers(fakeArgs); + assertNode.rejects(shouldReject); + }); + }); + }); + describe('isFunctionExecutedEvent()', () => { + it('returns true when args contain function_executed', () => { + assert.equal(isFunctionExecutedEvent({ + payload: { + type: 'function_executed', + }, + } as AnyMiddlewareArgs), true); + }); + it('returns false when args do not contain function_executed', () => { + assert.equal(isFunctionExecutedEvent({} as unknown as AnyMiddlewareArgs), false); + assert.equal(isFunctionExecutedEvent({ payload: { type: '' } } as unknown as AnyMiddlewareArgs), false); + }); + }); + describe('isFunctionInteractivityEvent()', () => { + it('returns false if args do not correspond to function interactivity event', () => { + assert.equal(isFunctionInteractivityEvent({} as AnyMiddlewareArgs & AllMiddlewareArgs), false); + assert.equal(isFunctionInteractivityEvent({ body: {} } as AnyMiddlewareArgs & AllMiddlewareArgs), false); + assert.equal(isFunctionInteractivityEvent({ body: { type: '' } } as unknown as AnyMiddlewareArgs & AllMiddlewareArgs), false); + }); + it('returns true when args correspond to function interactivity event', () => { + assert.equal(isFunctionInteractivityEvent({ + body: { + type: 'block_actions', + function_data: {} as FunctionExecutionContext, + }, + } as unknown as AnyMiddlewareArgs & AllMiddlewareArgs), true); + }); + }); + describe('passConstraint()', () => { + it('should pass when constraintKey does not exist in handler constraints', () => { + const constraintKey = 'callback_id'; + const constraints = { block_id: 'test_callback_id' }; + const payload = {}; + assert.equal(passConstraint(constraintKey, constraints, payload), true); + }); + it('should fail when constraintKey in constraints, but not in event payload', () => { + const constraintKey = 'callback_id'; + const constraints = { callback_id: 'test_callback_id' }; + const payload = {}; + assert.equal(passConstraint(constraintKey, constraints, payload), false); + }); + it('should pass when value of constraint in handler constraints matches corresponding value in event payload', () => { + const constraintKey = 'block_id'; + const constraintVal = 'test_callback_id'; + const constraints = { block_id: constraintVal }; + const payload = { block_id: constraintVal }; + assert.equal(passConstraint(constraintKey, constraints, payload), true); + }); + it('should pass when value of regex constraint matches value in event payload', () => { + const constraintKey = 'action_id'; + const constraints = { action_id: /approve_*.+/ }; + const payload = { action_id: 'approve_request' }; + assert.equal(passConstraint(constraintKey, constraints, payload), true); + }); + it('should fail when value of regex constraint does not match against value in event payload', () => { + const constraintKey = 'action_id'; + const constraints = { action_id: /approve_*.+/ }; + const payload = { action_id: 'not_the_request' }; + assert.equal(passConstraint(constraintKey, constraints, payload), false); + }); + }); + describe('validate', () => { + it('should throw a SlackFunctionInitializationError', async () => { + // set output of one of the tests hasCallbackId to return a no pass + const { validate } = await importSlackFunctionModule({ + hasCallbackId: () => ({ pass: false, msg: 'Test Message' }), + }); + const shouldNotBeCalled = sinon.spy(); + const validateFunc = () => validate('', shouldNotBeCalled); + assert.throws(validateFunc, SlackFunctionInitializationError); + assert.equal(shouldNotBeCalled.notCalled, true); + }); + }); + describe('hasCallbackId()', () => { + it('should pass if callback_id valid', () => { + const testId = 'approval_request'; + const testRes = hasCallbackId(testId); + assert.equal(testRes.pass, true); + }); + it('should fail if callback_id invalid', () => { + // force cast to trigger failure + const testId1 = undefined as unknown as string; + const testRes1 = hasCallbackId(testId1); + assert.equal(testRes1.pass, false); + + const testId2 = {} as string; + const testRes2 = hasCallbackId(testId2); + assert.equal(testRes2.pass, false); + + const testId3 = ''; + const testRes3 = hasCallbackId(testId3); + assert.equal(testRes3.pass, false); + }); + }); + describe('hasHandler()', () => { + it('should pass if handler supplied', () => { + const mockHandler = async () => {}; + const testRes = hasHandler('', mockHandler); + assert.equal(testRes.pass, true); + }); + it('should fail if handler undefined', () => { + const badHandler = undefined as unknown as Middleware; + const testRes1 = hasHandler('', badHandler); + assert.equal(testRes1.pass, false); + }); + }); +}); +describe('SlackFunction utils', () => { + describe('findMatchingManifestDefinition()', () => { + it('should error if manifest is missing a functions property', async () => { + // mock the getManifestData dependency to return + // a manifest that's missing functions property + const badManifestOutput = { + notFunctions: {}, + }; + const getManifestSpy = sinon.spy(() => badManifestOutput); + const { findMatchingManifestDefinition } = await importSlackFunctionModule({ + './cli/hook-utils/manifest': { + getManifestData: getManifestSpy, + }, + }); + + const findFunc = () => findMatchingManifestDefinition(''); + const expectedMsg = 'āš ļø Could not find functions in your project manifest.'; + assert.throws(findFunc, SlackFunctionInitializationError, expectedMsg); + assert(getManifestSpy.called); + }); + it('should pass if manifest defines function with matching callback_id', async () => { + const mockManifestOutput = { + functions: { + reverse_approval: {}, + }, + }; + const { findMatchingManifestDefinition } = await importSlackFunctionModule({ + './cli/hook-utils/manifest': { + getManifestData: () => mockManifestOutput, + }, + }); + + const res = findMatchingManifestDefinition('reverse_approval'); + assert.equal(res.matchFound, true); + assert.equal(res.fnKeys?.includes('reverse_approval'), true); + }); + it('should fail if manifest does not define function with matching callback_id', async () => { + const mockManifestOutput = { + functions: { + not_reverse_approval: {}, + }, + }; + const { findMatchingManifestDefinition } = await importSlackFunctionModule({ + './cli/hook-utils/manifest': { + getManifestData: () => mockManifestOutput, + }, + }); + + const res = findMatchingManifestDefinition('reverse_approval'); + assert.equal(res.matchFound, false); + assert.equal(res.fnKeys?.includes('reverse_approval'), false); + }); + }); +}); diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index bd48b8ed0..745068bda 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -1,43 +1,109 @@ +import util from 'util'; +import { Logger, LogLevel, ConsoleLogger } from '@slack/logger'; import { AllMiddlewareArgs, AnyMiddlewareArgs, Middleware, + SlackAction, + SlackActionMiddlewareArgs, SlackEventMiddlewareArgs, + SlackViewAction, + SlackViewMiddlewareArgs, } from './types'; +import { + ActionConstraints, + ViewConstraints, +} from './App'; + +import { + SlackFunctionCompleteError, + SlackFunctionExecutionError, + SlackFunctionInitializationError, +} from './errors'; + +// eslint-disable-next-line +export const manifestUtil = require('./cli/hook-utils/manifest'); + /* Types */ export interface SlackFunctionExecutedMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> { - complete: completeFunction + complete: CompleteFunction } -export interface completeFunction { - (args: completeFunctionArgs): Promise +export interface CompleteFunction { + (args: CompleteFunctionArgs): Promise, } -export type completeFunctionArgs = { +export interface CompleteFunctionArgs { // outputs are set by developer in the manifest file outputs?: Record, - error?: string + error?: string, +} + +export type AllSlackFunctionExecutedMiddlewareArgs = +SlackFunctionExecutedMiddlewareArgs & +SlackActionMiddlewareArgs & +AllMiddlewareArgs; + +interface FunctionInteractivityMiddleware { + constraints: FunctionInteractivityConstraints, + handler: Middleware | Middleware } -export type AllSlackFunctionExecutedMiddlewareArgs = SlackFunctionExecutedMiddlewareArgs & AllMiddlewareArgs; +type FunctionInteractivityConstraints = ActionConstraints | ViewConstraints; +// an array of Action constraints keys as strings +type ActionConstraintsKeys = Extract<(keyof ActionConstraints), string>[]; +type ViewConstraintsKeys = Extract<(keyof ViewConstraints), string>[]; + +interface SlackFnValidateResult { pass: boolean, msg?: string } +export interface ManifestDefinitionResult { + matchFound: boolean + fnKeys?: string[] +} /** - * A SlackFunction is a deterministic machine with - * specified outputs given specific inputs. - * -- - * Configure a SlackFunction's callback_id, inputs, and outputs - * in your project's manifest file (json or js). - * -- - * Slack will take care of providing inputs to your function - * via a function_execution event. Bolt handles delivering those - * to your function in the way you can expect of regular events, - * messages, shortcuts commands, etc. - * -- - * When initiating an instance of SlackFunction below, you supply the - * callback you want to process the supplied inputs and what logical - * conditions determine success or failure in your use case. - * Call the supplied utility complete with either outputs or an error + * *SlackFunction* + * + * Configure a SlackFunction's callbackId, + * and any expected inputs and outputs + * in your project's manifest file (json or js). + * + * Use this class to declare your handling logic: + * + * Example: + * ``` + * const myFunc = new SlackFunction('fn_callback_id', () => {}); + * ``` + * You can also declare + * optional handlers for `block_action` and `view` events + * related to your function. + * + * Example: + * ``` + * myFunc.action('action_id', () => {}) + * .view('view_callback_id', () => {}); + * ``` + * Note: This is not equivalent to app.action() or app.view() + * + * *Completing your function* + * + * Call the supplied utility `complete` in any handler + * with either outputs or an error or nothing when your + * function is done. This tells Slack whether to proceed + * with any next steps in any workflow this function might + * be included in. Your outputs should match what is defined + * in your manifest. + * + * Example: + * ``` + * const myFunc = new SlackFunction('fn_callback_id', ({ complete }) => { + * // do my work here + * + * complete() // or + * complete({ outputs: {} }); // or + * complete({ error: {} }); + * }); + * ``` * */ export class SlackFunction { /** @@ -47,75 +113,412 @@ export class SlackFunction { private callbackId: string; /** - * @description fn to to process corresponding + * @description handler to to process corresponding * function_executed event */ - private fn: Middleware; + private handler: Middleware; + + private interactivityHandlers: FunctionInteractivityMiddleware[]; + + private logger: Logger; + + public constructor(callbackId: string, handler: Middleware) { + validate(callbackId, handler); - public constructor(callbackId: string, fn: Middleware) { - // TODO: Add validation step this.callbackId = callbackId; - this.fn = fn; + this.handler = handler; + this.interactivityHandlers = []; + + // set an initial default logging + const logger = new ConsoleLogger(); + logger.setName(`SlackFunction: [${this.callbackId}]`); + logger.setLevel(LogLevel.DEBUG); + this.logger = logger; + } + + /** + * Attach a block_actions interactivity handler to your SlackFunction + * + * ``` + * Example: + * const actionHandler = async () => {}; + * const actionHandler1 = async () => {}; + * myFunc.action("id", actionHandler).action("id1", actionHandler1); + * ``` + * + * @param actionIdOrConstraints Provide an action_id string + * corresponding to the value supplied in your blocks or a + * constraint object of type ActionConstraints + * + * ``` + * Example: + * myFunc.action({ type: "action_submission" }); + * myFunc.action({ action_id: "id" }, actionHandler); + * ``` + * @param handler Provide a handler function + * @returns SlackFunction instance + */ + public action< + Action extends SlackAction = SlackAction, + Constraints extends ActionConstraints = ActionConstraints, + >( + actionIdOrConstraints: string | RegExp | Constraints, + handler: Middleware, + ): this { + // normalize constraints + const constraints: ActionConstraints = ( + typeof actionIdOrConstraints === 'string' || + util.types.isRegExp(actionIdOrConstraints) + ) ? + { action_id: actionIdOrConstraints } : + actionIdOrConstraints; + + // declare our valid constraints keys + const validConstraintsKeys: ActionConstraintsKeys = ['action_id', 'block_id', 'callback_id', 'type']; + // cast to string array for convenience + const validConstraintsKeysAsStrings = validConstraintsKeys as string[]; + + errorIfInvalidConstraintKeys(constraints, validConstraintsKeysAsStrings, handler); + + this.interactivityHandlers.push({ constraints, handler }); + return this; + } + + /** + * Attach a view_submission or view_closed interactivity handler + * to your SlackFunction + * + * ``` + * Example: + * const viewHandler = async () => {}; + * const viewHandler1 = async () => {}; + * myFunc.view("id", viewHandler).view("id1", viewHandler1) + * ``` + * + * @param callbackIdOrConstraints Provide a `callback_id` string + * a constraint object of type ViewConstraints + * + * ``` + * Example: + * myFunc.view({ type: "view_submission" }); + * myFunc.view({ callback_id: "id", }, viewHandler) + * ``` + * + * @param handler Provide a handler function + * @returns SlackFunction instance + */ + public view( + callbackIdOrConstraints: string | RegExp | ViewConstraints, + handler: Middleware>, + ): this { + // normalize constraints + const constraints: ViewConstraints = ( + typeof callbackIdOrConstraints === 'string' || + util.types.isRegExp(callbackIdOrConstraints) + ) ? + { callback_id: callbackIdOrConstraints, type: 'view_submission' } : + callbackIdOrConstraints; + + // declare our valid constraints keys + const validConstraintsKeys: ViewConstraintsKeys = ['callback_id', 'type']; + // cast to string array for convenience + const validConstraintsKeysAsStrings = validConstraintsKeys as string[]; + + errorIfInvalidConstraintKeys(constraints, validConstraintsKeysAsStrings, handler); + + this.interactivityHandlers.push({ constraints, handler }); + return this; + } + + private matchesFuncConstraints(args: AnyMiddlewareArgs): boolean { + if ('function' in args.payload) { + return this.callbackId === args.payload.function.callback_id; + } + return false; } - /* Utility */ + /** + * Returns a a single middleware to global + * middleware chain. Responsible for returning + * handlers required for either function execution + * or function interactivity event handlering to + * the global event processing chain. + */ public getMiddleware(): Middleware { return async (args): Promise => { - if (isFunctionExecutedEvent(args) && this.matchesConstraints(args)) { - return this.run(args); + // handle function executed event + if ((isFunctionExecutedEvent(args) && this.matchesFuncConstraints(args))) { + try { + this.logger.debug('šŸš€ Executing my main handler:', this.handler); + return await this.runHandler(args); + } catch (error) { + this.logger.error('āš ļø Something went wrong executing:', this.handler); + } + } + // handle function interactivity events + if (isFunctionInteractivityEvent(args)) { + return this.runInteractivityHandlers(args); } + // call the next middleware in the global middleware chain return args.next(); }; } - private matchesConstraints(args: AnyMiddlewareArgs): boolean { - if ('function' in args.payload) { - return this.callbackId === args.payload.function.callback_id; + public runHandler = async (args: AnyMiddlewareArgs & AllMiddlewareArgs): Promise => { + const handlerArgs = this.prepareFnArgs(args); + this.handler(handlerArgs); + }; + + public runInteractivityHandlers = async (args: AnyMiddlewareArgs & AllMiddlewareArgs): Promise => { + const jobs: Promise[] = []; + for (let i = 0; i < this.interactivityHandlers.length; i += 1) { + const { constraints, handler } = this.interactivityHandlers[i]; + if (passInteractivityConstraints(constraints, args)) { + const handlerArgs = this.prepareFnArgs(args); + + // helpful logging + this.logger.debug('šŸš€ Executing my interactive handler:', handler); + this.logger.debug('šŸš€ Registered with constraints:', constraints); + + jobs.push(handler(handlerArgs)); + } } + + return new Promise((resolve, reject) => { + Promise.all(jobs).then((_) => resolve()).catch((error) => { + const msg = `āš ļø A SlackFunction handler promise rejected. Error details: ${error}`; + const err = new SlackFunctionExecutionError(msg); + reject(err); + }); + }); + }; + + /** + * Ensure that SlackFunction `complete()` utility callback is provided + * as args to all function and interactivity handlers registered on + * SlackFunction instance + * @param args middleware arguments + */ + private prepareFnArgs(args: AnyMiddlewareArgs & AllMiddlewareArgs): AllSlackFunctionExecutedMiddlewareArgs { + const { next: _next, ...subArgs } = args; + // eslint-disable-next-line + const preparedArgs: any = { ...subArgs }; + // ensure all handlers have complete utility + preparedArgs.complete = preparedArgs.complete ?? this.createComplete(preparedArgs); + return preparedArgs; + } + + /** + * Creates a `complete()` utility function + * + * @param args + * @returns A `complete()` utility callback + * which can be accessed from any SlackFunction + * handler and used to to complete your SlackFunction. + * + * ``` + * Example: + * const handler = async ({ complete }) => { complete() }; + * const myFunc = new SlackFunction("id", handler); + * ``` + */ + // eslint-disable-next-line + private createComplete(args: any): CompleteFunction { + const { payload, body, client } = args; + + // gets the function execution id from a function executed event + let { function_execution_id } = payload; + + // gets the function execution id from the function data in a function interactivity event + if (function_execution_id === undefined && body !== undefined && 'function_data' in body) { + function_execution_id = body.function_data.execution_id; + } + + // if stil undefined, error + if (function_execution_id === undefined) { + const msg = 'āš ļø Cannot generate required complete utility without a function_execution_id'; + throw new SlackFunctionCompleteError(msg); + } + + // return the utility callback + return ({ outputs, error }: CompleteFunctionArgs = {}) => { + // Slack API requires functions complete with either outputs or error, not both + if (outputs && error) { + throw new SlackFunctionCompleteError('āš ļø Cannot complete with outputs and error message supplied'); + } + // if user has supplied outputs OR has supplied neither outputs nor error + if (outputs !== undefined || (outputs === undefined && error === undefined)) { + // helpful logging + this.logger.debug('šŸš€ Attempting to complete with outputs:', outputs); + return client.apiCall('functions.completeSuccess', { + outputs: outputs ?? {}, + function_execution_id, + }); + } if (error !== undefined) { + this.logger.debug('šŸš€ Attempting to complete with error:', error); + return client.apiCall('functions.completeError', { + error, + function_execution_id, + }); + } + return null; + }; + } +} + +/* Event handling validation */ + +export function isFunctionExecutedEvent(args: AnyMiddlewareArgs): boolean { + if (args.payload === undefined) { return false; } + return (args.payload.type === 'function_executed'); +} - private run = async (args: AnyMiddlewareArgs & AllMiddlewareArgs): Promise => { - const fnArgs = prepareFnArgs(args); - this.fn(fnArgs); - }; +export function isFunctionInteractivityEvent(args: AnyMiddlewareArgs & AllMiddlewareArgs): boolean { + const allowedInteractivityTypes = [ + 'block_actions', 'view_submission', 'view_closed']; + if (args.body === undefined) return false; + return ( + allowedInteractivityTypes.includes(args.body.type) && + ('function_data' in args.body) + ); } -function isFunctionExecutedEvent(args: AnyMiddlewareArgs): boolean { - return args.payload.type === 'function_executed'; +function passInteractivityConstraints( + constraints: FunctionInteractivityConstraints, + args: AnyMiddlewareArgs & AllMiddlewareArgs, +): boolean { + const { payload, body } = args; + + if (!passConstraint('type', constraints, body)) return false; + if (!passConstraint('block_id', constraints, payload)) return false; + if (!passConstraint('action_id', constraints, payload)) return false; + + if ('callback_id' in constraints) { + if ('view' in body) { + if (!passConstraint('callback_id', constraints, body.view)) return false; + } else { + return false; + } + } + return true; } -/** - * Adds custom utilities success and failure functions to - * arguments - * @param args provided arguments - */ -function prepareFnArgs(args: AnyMiddlewareArgs & AllMiddlewareArgs): AllSlackFunctionExecutedMiddlewareArgs { - const { next: _next, ...subArgs } = args; - const preparedArgs: any = { ...subArgs }; - preparedArgs.complete = createComplete(preparedArgs); - return preparedArgs; -} - -function createComplete(args: any): completeFunction { - const { client, event } = args; - const { function_execution_id } = event; - - return ({ outputs, error }: completeFunctionArgs) => { - if (outputs && error) { - throw new Error("Cannot complete a function with both outputs and error message"); +export function passConstraint( + constraintKey: Extract<(keyof ActionConstraints), string> | Extract<(keyof ViewConstraints), string>, + constraints: FunctionInteractivityConstraints, + // eslint-disable-next-line + payload: any, +): boolean { + let regExpMatches: RegExpMatchArray | null; + let pass: boolean = true; + + // user provided constraint key, e.g. action_id + if (constraintKey in constraints) { + // event payload contains constraint key + if (constraintKey in payload) { + // eslint-disable-next-line + // @ts-ignore - we ensure constraintKey exists in constraints above + const constraintVal = constraints[constraintKey]; + if (typeof constraintVal === 'string') { + if (constraintVal !== payload[constraintKey]) { + pass = false; + } + } else { + // treat constraintKey as regular expression and check payload for matches + regExpMatches = payload[constraintKey].match(constraintVal); + if (regExpMatches === null) { + pass = false; + } + } + } else { + // user provided constraint, but payload doesn't contain key value + pass = false; } - // if user has supplied outputs OR has supplied neither outputs nor error - if (outputs !== undefined || (outputs === undefined && error === undefined)) { - return client.apiCall('functions.completeSuccess', { - outputs, - function_execution_id - }) - } else if (error !== undefined) { - return client.apiCall('functions.completeError', { - error, - function_execution_id - }) + } + return pass; +} + +/* Initialization validators */ +export function validate(callbackId: string, handler: Middleware): void { + const tests = [hasCallbackId, hasMatchingManifestDefinition, hasHandler]; + tests.forEach((test) => { + const res = test(callbackId, handler); + if (!res.pass) { + throw new SlackFunctionInitializationError(res.msg); } + }); +} + +export function errorIfInvalidConstraintKeys( + constraints: FunctionInteractivityConstraints, + validKeys: string[], + handler: Middleware | Middleware>, +): void { + const invalidKeys = Object.keys(constraints).filter( + (key) => !validKeys.includes(key), + ); + if (invalidKeys.length > 0) { + const msg = `āš ļø You supplied invalid constraints: ${invalidKeys} for handler: ${handler}`; + throw new SlackFunctionInitializationError(msg); + } +} + +export function hasCallbackId(callbackId: string, _?: Middleware): SlackFnValidateResult { + const res: SlackFnValidateResult = { pass: true, msg: '' }; + if ( + callbackId === undefined || + typeof callbackId !== 'string' || + callbackId === '' + ) { + res.pass = false; + res.msg = 'SlackFunction expects a callback_id string as its first argument'; } -} \ No newline at end of file + return res; +} + +export function hasMatchingManifestDefinition( + callbackId: string, + _?: Middleware, +): SlackFnValidateResult { + const res: SlackFnValidateResult = { pass: true, msg: '' }; + const { matchFound, fnKeys } = findMatchingManifestDefinition(callbackId); + if (!matchFound) { + res.pass = false; + res.msg = `Provided SlackFunction callback_id: [${callbackId}] does not have a matching manifest ` + + 'definition. Please check your manifest file.\n' + + `Definitions we were able to find: ${fnKeys}`; + } + return res; +} + +export function findMatchingManifestDefinition(callbackId: string): ManifestDefinitionResult { + const result: ManifestDefinitionResult = { matchFound: false, fnKeys: [] }; + // call the hook to get the manifest + const manifest = manifestUtil.getManifestData(process.cwd()); + + // manifest file must exist in the project + if (!('functions' in manifest)) { + const msg = 'āš ļø Could not find functions in your project manifest.'; + throw new SlackFunctionInitializationError(msg); + } + + try { + // set the keys + result.fnKeys = Object.keys(manifest.functions); + result.matchFound = result.fnKeys.includes(callbackId); + } catch (error) { + throw new SlackFunctionInitializationError('Something went wrong when trying to read your manifest function definitions'); + } + return result; +} + +export function hasHandler(_: string, handler: Middleware): SlackFnValidateResult { + const res: SlackFnValidateResult = { pass: true, msg: '' }; + if (handler === undefined) { + res.pass = false; + res.msg = 'You must provide a SlackFunction handler'; + } + return res; +} diff --git a/src/cli/get-manifest.js b/src/cli/get-manifest.js index 1d97776d2..0a567fbfe 100755 --- a/src/cli/get-manifest.js +++ b/src/cli/get-manifest.js @@ -1,6 +1,5 @@ #!/usr/bin/env node -const merge = require('deepmerge'); -const { unionMerge, readManifestJSONFile, readImportedManifestFile, hasManifest } = require('./hook-utils/manifest'); +const { getManifestData } = require('./hook-utils/manifest'); /** * Implements the get-manifest script hook required by the Slack CLI @@ -10,29 +9,8 @@ const { unionMerge, readManifestJSONFile, readImportedManifestFile, hasManifest * properties will merge into any manifest.json */ (function _(cwd) { - const file = 'manifest'; - let manifest = {}; - - // look for a manifest JSON - const manifestJSON = readManifestJSONFile(cwd, `${file}.json`); - - // look for manifest.js - // stringify and parses the JSON in order to ensure that objects with .toJSON() functions - // resolve properly. This is a known behavior for CustomType - const manifestJS = JSON.parse(JSON.stringify(readImportedManifestFile(cwd, `${file}.js`))); - - if (!hasManifest(manifestJS, manifestJSON)) { - throw new Error('Unable to find a manifest file in this project'); - } - - // manage manifest merge - if (manifestJSON) { - manifest = merge(manifest, manifestJSON, { arrayMerge: unionMerge}); - } - if (manifestJS) { - manifest = merge(manifest, manifestJS, { arrayMerge: unionMerge }); - } + let manifest = getManifestData(cwd); - // write the merged manifest to stdout + // write manifest to stdout console.log(JSON.stringify(manifest)); -}(process.cwd())); \ No newline at end of file +}( process.cwd())); \ No newline at end of file diff --git a/src/cli/hook-utils/manifest.js b/src/cli/hook-utils/manifest.js index 3a1e063eb..3a6856305 100755 --- a/src/cli/hook-utils/manifest.js +++ b/src/cli/hook-utils/manifest.js @@ -1,5 +1,6 @@ const fs = require('fs'); const path = require('path'); +const merge = require('deepmerge'); // helper array merge function function unionMerge(array1, array2) { @@ -7,10 +8,10 @@ function unionMerge(array1, array2) { } // look for manifest.json in the current working directory -function readManifestJSONFile(cwd, filename) { +function readManifestJSONFile(searchDir, filename) { let jsonFilePath, manifestJSON; try { - jsonFilePath = find(cwd, filename); + jsonFilePath = find(searchDir, filename); if (fs.existsSync(jsonFilePath)) { manifestJSON = JSON.parse(fs.readFileSync(jsonFilePath, 'utf8')); } @@ -21,11 +22,11 @@ function readManifestJSONFile(cwd, filename) { } // look for a manifest file in the current working directory -function readImportedManifestFile(cwd, filename) { +function readImportedManifestFile(searchDir, filename) { let importedManifestFilePath, manifestImported; try { - importedManifestFilePath = find(cwd, filename); + importedManifestFilePath = find(searchDir, filename); if (fs.existsSync(importedManifestFilePath)) { manifestImported = require(`${importedManifestFilePath}`); @@ -77,7 +78,34 @@ function find(currentPath, targetFilename) { } } +function getManifestData(searchDir) { + const file = 'manifest'; + let manifest = {}; + + // look for a manifest JSON + const manifestJSON = readManifestJSONFile(searchDir, `${file}.json`); + + // look for manifest.js + // stringify and parses the JSON in order to ensure that objects with .toJSON() functions + // resolve properly. This is a known behavior for CustomType + const manifestJS = JSON.parse(JSON.stringify(readImportedManifestFile(searchDir, `${file}.js`))); + + if (!hasManifest(manifestJS, manifestJSON)) { + throw new Error('Unable to find a manifest file in this project'); + } + + // manage manifest merge + if (manifestJSON) { + manifest = merge(manifest, manifestJSON, { arrayMerge: unionMerge}); + } + if (manifestJS) { + manifest = merge(manifest, manifestJS, { arrayMerge: unionMerge }); + } + return manifest; +} + module.exports = { + getManifestData, unionMerge, readManifestJSONFile, readImportedManifestFile, diff --git a/src/errors.ts b/src/errors.ts index b8ed369c7..c0262ae1e 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -34,6 +34,10 @@ export enum ErrorCode { UnknownError = 'slack_bolt_unknown_error', WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error', + + SlackFunctionInitializationError = 'slack_bolt_slack_function_initialization_error', + SlackFunctionExecutionError = 'slack_bolt_slack_function_execution_error', + SlackFunctionCompleteError = 'slack_bolt_slack_function_initialization_error', } export class UnknownError extends Error implements CodedError { @@ -138,3 +142,15 @@ export class MultipleListenerError extends Error implements CodedError { export class WorkflowStepInitializationError extends Error implements CodedError { public code = ErrorCode.WorkflowStepInitializationError; } + +export class SlackFunctionInitializationError extends Error implements CodedError { + public code = ErrorCode.SlackFunctionInitializationError; +} + +export class SlackFunctionCompleteError extends Error implements CodedError { + public code = ErrorCode.SlackFunctionCompleteError; +} + +export class SlackFunctionExecutionError extends Error implements CodedError { + public code = ErrorCode.SlackFunctionExecutionError; +} diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 48fe4dcfa..78dbe3a74 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -121,6 +121,7 @@ export const onlyViewActions: Middleware { diff --git a/src/middleware/process.ts b/src/middleware/process.ts index ac24e0fd9..7934e01f4 100644 --- a/src/middleware/process.ts +++ b/src/middleware/process.ts @@ -10,7 +10,6 @@ export default async function processMiddleware( logger: Logger, last: () => Promise, ): Promise { - let lastCalledMiddlewareIndex = -1; async function invokeMiddleware(toCallMiddlewareIndex: number): ReturnType> { if (lastCalledMiddlewareIndex >= toCallMiddlewareIndex) { diff --git a/src/types/actions/block-action.ts b/src/types/actions/block-action.ts index 3c05ce576..668b0b6a8 100644 --- a/src/types/actions/block-action.ts +++ b/src/types/actions/block-action.ts @@ -1,7 +1,7 @@ import { PlainTextElement, Confirmation, Option } from '@slack/types'; import { StringIndexed } from '../helpers'; import { ViewOutput, ViewStateValue } from '../view'; -import { FunctionExecutionContext, FunctionInteractivityContext } from '../functions'; +import { FunctionContext } from '../functions'; /** * All known actions from in Slack's interactive elements @@ -208,7 +208,7 @@ export interface PlainTextInputAction extends BasicElementAction<'plain_text_inp * * This describes the entire JSON-encoded body of a request from Slack's Block Kit interactive components. */ -export interface BlockAction { +export interface BlockAction extends FunctionContext { type: 'block_actions'; actions: ElementAction[]; team: { @@ -260,11 +260,6 @@ export interface BlockAction, function: { @@ -10,16 +10,16 @@ title: string, description: string, type: string, - input_parameters: [], - output_parameters: [], + input_parameters: any[], + output_parameters: any[], app_id: string, date_updated: number, } } -/** -* FunctionInteractivityContext -*/ +/** + * FunctionInteractivityContext + */ export interface FunctionInteractivityContext { interactor: { secret: string, @@ -27,3 +27,11 @@ export interface FunctionInteractivityContext { } interactivity_pointer: string, } + +// exists for any events occurring in a function execution context +// extend this interface to add function context +export interface FunctionContext { + bot_access_token?: string; + function_data?: FunctionExecutionContext; + interactivity?: FunctionInteractivityContext; +} diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 5c7b91d79..9f43e7ff0 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -47,15 +47,15 @@ export interface Context extends StringIndexed { /** * A bot access token, which starts with `xwfp-`. * This is a just-in-time token. - * + * * A bot access token is a short-lived (JIT) token - * sent along in any event payload associated with - * for one of your apps custom Slack Functions. - * + * sent along in any event payload associated with + * for one of your apps custom Slack Functions. + * * When present, client should use this token over - * any other regular xoxb or xoxb. - * - * Read about custom Slack Functions @ https://api.slack.com/future/functions + * any other regular xoxb or xoxb. + * + * Read about custom Slack Functions @ https://api.slack.com/future/functions * */ botAccessToken?: string; /** diff --git a/src/types/view/index.ts b/src/types/view/index.ts index 5d6796f12..8a83d14b9 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -1,5 +1,6 @@ import { Block, KnownBlock, PlainTextElement, View } from '@slack/types'; import { AckFn, RespondFn } from '../utilities'; +import { FunctionContext } from '../functions'; /** * Known view action types @@ -39,7 +40,7 @@ export interface ViewResponseUrl { * * This describes the entire JSON-encoded body of a view_submission event. */ -export interface ViewSubmitAction { +export interface ViewSubmitAction extends FunctionContext { type: 'view_submission'; team: { id: string; @@ -70,7 +71,7 @@ export interface ViewSubmitAction { * * This describes the entire JSON-encoded body of a view_closed event. */ -export interface ViewClosedAction { +export interface ViewClosedAction extends FunctionContext { type: 'view_closed'; team: { id: string; @@ -93,6 +94,7 @@ export interface ViewClosedAction { id: string; name: string; }; + } /** diff --git a/tsconfig.json b/tsconfig.json index 1d45f020a..2b19ba460 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ // "resolveJsonModule": true, // "lib": [], /* Specify library files to be included in the compilation. */ - // "allowJs": true, /* Allow javascript files to be compiled. */ + "allowJs": true, /* Allow javascript files to be compiled. */ // "checkJs": true, /* Report errors in .js files. */ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ "declaration": true, /* Generates corresponding '.d.ts' file. */ From 86fba82ec23f92ca384755b5378349e2513d5cc3 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 29 Aug 2022 14:37:46 -0700 Subject: [PATCH 08/16] Pin typescript version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33f0bcff4..3696a3e49 100644 --- a/package.json +++ b/package.json @@ -93,7 +93,7 @@ "source-map-support": "^0.5.12", "ts-node": "^8.1.0", "tsd": "^0.22.0", - "typescript": "^4.1.0" + "typescript": "4.7.4" }, "tsd": { "directory": "types-tests" From ef261ac27620435a63023d6cf45e53af2d661ca6 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 29 Aug 2022 15:00:05 -0700 Subject: [PATCH 09/16] Change node import type --- src/SlackFunction.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SlackFunction.spec.ts b/src/SlackFunction.spec.ts index 0e9ef2b8f..e5d2c58ee 100644 --- a/src/SlackFunction.spec.ts +++ b/src/SlackFunction.spec.ts @@ -1,5 +1,5 @@ import 'mocha'; -import * as assertNode from 'node:assert/strict'; +import * as assertNode from 'assert'; import { assert } from 'chai'; import { expectType } from 'tsd'; import rewiremock from 'rewiremock'; From 766f615dadee8eecca38f982591ded06aeb8a149 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 29 Aug 2022 15:42:07 -0700 Subject: [PATCH 10/16] Improve comment value --- src/App.ts | 21 ++++++++++++++++----- src/SlackFunction.ts | 22 +++++++++++++++------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/App.ts b/src/App.ts index 69309fa8e..396f64eaa 100644 --- a/src/App.ts +++ b/src/App.ts @@ -620,8 +620,6 @@ export default class App /** * Register a Slack Function * - * @param callbackId the id of the function as defined in manifest - * * @param slackFn a main function to register * * */ @@ -986,7 +984,20 @@ export default class App } /** - * Set the Bot Access Token if it exists in event payload + * Set the Bot Access Token if it exists in event payload to the context. + * + * A bot_access_token will exist in any payload that has been generated + * in the context of a Slack Function execution. This includes function_execution + * events themselves, as well as interactivity payloads (e.g. block_actions, views). + * + * This token must be used for further Slack API calls which are relevant to this + * Slack Function execution in order for any interactivity context data to be + * properly sent by Slack. + * + * Bolt will set this value in the event Context and + * use this token in place of any other token (for example the token + * that the App was configured with) to initialize the Web Client. + * * Sometimes the bot_access_token is located in the event * Sometimes it is located directly in the body. */ @@ -1610,7 +1621,7 @@ function isBlockActionOrInteractiveMessageBody( } /** - * Returns a bot token, bot access token or user token for client, say() + * Returns in order of preference, a bot token, bot access token or user token for client, say() * */ function selectToken(context: Context): string | undefined { return context.botAccessToken ?? context.botToken ?? context.userToken; @@ -1639,7 +1650,7 @@ function isEventTypeToSkipAuthorize(eventType: string) { addAppMetadata({ name: packageJson.name, version: packageJson.version }); // types -export type ListenerArgs = Pick & { +type ListenerArgs = Pick & { /** Say function might be set below */ say?: SayFn; /** Respond function might be set below */ diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index 745068bda..4c330c827 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -87,12 +87,14 @@ export interface ManifestDefinitionResult { * * *Completing your function* * - * Call the supplied utility `complete` in any handler - * with either outputs or an error or nothing when your - * function is done. This tells Slack whether to proceed + * Call the supplied utility `complete` when your function is + * done executing. This tells Slack it can proceed * with any next steps in any workflow this function might - * be included in. Your outputs should match what is defined - * in your manifest. + * be included in. + * + * Supply outputs or an error or nothing when your + * function is done. Note, your outputs should match what you + * have defined in your in your manifest. * * Example: * ``` @@ -104,6 +106,12 @@ export interface ManifestDefinitionResult { * complete({ error: {} }); * }); * ``` + * + * Call `complete()` from your main handler or + * an interactivity handler. Note, once a function is + * completed (either with outputs or an error), interactions + * from views generated in the course of function execution + * will no longer trigger associated handlers, so remember to clean those up. * */ export class SlackFunction { /** @@ -369,7 +377,7 @@ export class SlackFunction { /* Event handling validation */ export function isFunctionExecutedEvent(args: AnyMiddlewareArgs): boolean { - if (args.payload === undefined) { + if (args.payload === undefined || args.payload === null) { return false; } return (args.payload.type === 'function_executed'); @@ -378,7 +386,7 @@ export function isFunctionExecutedEvent(args: AnyMiddlewareArgs): boolean { export function isFunctionInteractivityEvent(args: AnyMiddlewareArgs & AllMiddlewareArgs): boolean { const allowedInteractivityTypes = [ 'block_actions', 'view_submission', 'view_closed']; - if (args.body === undefined) return false; + if (args.body === undefined || args.body === null) return false; return ( allowedInteractivityTypes.includes(args.body.type) && ('function_data' in args.body) From 64039b2f74d2ed3b047f9038a6dc685bee24f248 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Mon, 29 Aug 2022 16:22:43 -0700 Subject: [PATCH 11/16] Add test for runHandler error --- src/SlackFunction.spec.ts | 23 +++++++++++++++++++++++ src/SlackFunction.ts | 18 +++++++++--------- 2 files changed, 32 insertions(+), 9 deletions(-) diff --git a/src/SlackFunction.spec.ts b/src/SlackFunction.spec.ts index e5d2c58ee..b7576aa8a 100644 --- a/src/SlackFunction.spec.ts +++ b/src/SlackFunction.spec.ts @@ -138,6 +138,29 @@ describe('SlackFunction module', () => { await testFunc.runHandler(fakeArgs); assert(spyHandler.called); }); + it('should gracefully handle errors if promise rejects', async () => { + // set up the slack function + const mockFunctionCallbackId = 'reverse_approval'; + const { SlackFunction } = await importSlackFunctionModule(withMockValidManifestUtil(mockFunctionCallbackId)); + const spyHandler = sinon.spy((async () => { + throw new Error('BOOM!'); + }) as unknown as Middleware); + const testFunc = new SlackFunction(mockFunctionCallbackId, spyHandler); + + // set up event args + const fakeArgs = { + next: () => {}, + payload: { + function_execution_id: '1234', + }, + body: {}, + client: {} as WebClient, + } as AnyMiddlewareArgs & AllMiddlewareArgs; + + // ensure handler is called + const shouldNotThrow = async () => testFunc.runHandler(fakeArgs); + assert.doesNotThrow(shouldNotThrow); + }); }); describe('runInteractivityHandlers', () => { it('should execute all provided callbacks', async () => { diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index 4c330c827..e28051d33 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -253,15 +253,10 @@ export class SlackFunction { * the global event processing chain. */ public getMiddleware(): Middleware { - return async (args): Promise => { + return (args): Promise => { // handle function executed event if ((isFunctionExecutedEvent(args) && this.matchesFuncConstraints(args))) { - try { - this.logger.debug('šŸš€ Executing my main handler:', this.handler); - return await this.runHandler(args); - } catch (error) { - this.logger.error('āš ļø Something went wrong executing:', this.handler); - } + return this.runHandler(args); } // handle function interactivity events if (isFunctionInteractivityEvent(args)) { @@ -273,8 +268,13 @@ export class SlackFunction { } public runHandler = async (args: AnyMiddlewareArgs & AllMiddlewareArgs): Promise => { - const handlerArgs = this.prepareFnArgs(args); - this.handler(handlerArgs); + this.logger.debug('šŸš€ Executing my main handler:', this.handler); + try { + const handlerArgs = this.prepareFnArgs(args); + await this.handler(handlerArgs); + } catch (err) { + this.logger.error('āš ļø Something went wrong executing:', this.handler, '\nāš ļø Error Details:', err); + } }; public runInteractivityHandlers = async (args: AnyMiddlewareArgs & AllMiddlewareArgs): Promise => { From 2bfbcbb13d115d707a7c3f1711fd842f7aa01d88 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 30 Aug 2022 10:54:26 -0700 Subject: [PATCH 12/16] Remove DefineDatastore from exports --- src/Manifest.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Manifest.ts b/src/Manifest.ts index 4fc13f1e3..75eca5605 100644 --- a/src/Manifest.ts +++ b/src/Manifest.ts @@ -7,7 +7,6 @@ import { Schema, ManifestSchema, DefineOAuth2Provider, - DefineDatastore, } from '@slack/deno-slack-sdk'; export const Manifest = (definition: SlackManifestType): ManifestSchema => { @@ -23,7 +22,6 @@ export { Schema, SlackManifest, DefineType, - DefineDatastore, }; export type { From e3b3ce228b4a3cf1a9dd50eef7e2343bf00b4228 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 30 Aug 2022 11:05:09 -0700 Subject: [PATCH 13/16] Update FunctionExectionContext to reflect payload changes --- src/types/actions/block-action.ts | 4 ++-- src/types/functions/index.ts | 8 -------- src/types/view/index.ts | 4 ++-- 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/types/actions/block-action.ts b/src/types/actions/block-action.ts index 668b0b6a8..fcf702148 100644 --- a/src/types/actions/block-action.ts +++ b/src/types/actions/block-action.ts @@ -242,9 +242,9 @@ export interface BlockAction, function: { - id: string, callback_id: string, - title: string, - description: string, - type: string, - input_parameters: any[], - output_parameters: any[], - app_id: string, - date_updated: number, } } diff --git a/src/types/view/index.ts b/src/types/view/index.ts index 8a83d14b9..dc1fb46c9 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -55,8 +55,8 @@ export interface ViewSubmitAction extends FunctionContext { }; view: ViewOutput; api_app_id: string; - token: string; - trigger_id: string; // undocumented + token?: string; + trigger_id?: string; // undocumented // exists for enterprise installs is_enterprise_install?: boolean; enterprise?: { From 83b45a685ad61840d09a85d75c6b1cedec738b8c Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 30 Aug 2022 19:09:00 -0700 Subject: [PATCH 14/16] botAccessToken renamed slackFunctionBotAccessToken --- src/App.ts | 10 +++++----- src/SlackFunction.ts | 10 ++++++---- src/cli/get-manifest.js | 2 +- src/types/middleware.ts | 2 +- 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/App.ts b/src/App.ts index 396f64eaa..c476ae9c0 100644 --- a/src/App.ts +++ b/src/App.ts @@ -1001,16 +1001,16 @@ export default class App * Sometimes the bot_access_token is located in the event * Sometimes it is located directly in the body. */ - let botAccessToken = body.bot_access_token; - if (botAccessToken === undefined && 'event' in body) { - botAccessToken = body.event.bot_access_token; + let slackFunctionBotAccessToken = body.bot_access_token; + if (slackFunctionBotAccessToken === undefined && 'event' in body) { + slackFunctionBotAccessToken = body.event.bot_access_token; } // Declare the event context const context: Context = { ...authorizeResult, ...event.customProperties, - botAccessToken, + slackFunctionBotAccessToken, isEnterpriseInstall, retryNum: event.retryNum, retryReason: event.retryReason, @@ -1624,7 +1624,7 @@ function isBlockActionOrInteractiveMessageBody( * Returns in order of preference, a bot token, bot access token or user token for client, say() * */ function selectToken(context: Context): string | undefined { - return context.botAccessToken ?? context.botToken ?? context.userToken; + return context.slackFunctionBotAccessToken ?? context.botToken ?? context.userToken; } function buildRespondFn( diff --git a/src/SlackFunction.ts b/src/SlackFunction.ts index e28051d33..3bf45e701 100644 --- a/src/SlackFunction.ts +++ b/src/SlackFunction.ts @@ -103,7 +103,7 @@ export interface ManifestDefinitionResult { * * complete() // or * complete({ outputs: {} }); // or - * complete({ error: {} }); + * complete({ error: "error details here" }); * }); * ``` * @@ -448,10 +448,12 @@ export function passConstraint( return pass; } +// all tests to run +const validations = [hasCallbackId, hasMatchingManifestDefinition, hasHandler]; + /* Initialization validators */ export function validate(callbackId: string, handler: Middleware): void { - const tests = [hasCallbackId, hasMatchingManifestDefinition, hasHandler]; - tests.forEach((test) => { + validations.forEach((test) => { const res = test(callbackId, handler); if (!res.pass) { throw new SlackFunctionInitializationError(res.msg); @@ -494,7 +496,7 @@ export function hasMatchingManifestDefinition( const { matchFound, fnKeys } = findMatchingManifestDefinition(callbackId); if (!matchFound) { res.pass = false; - res.msg = `Provided SlackFunction callback_id: [${callbackId}] does not have a matching manifest ` + + res.msg = `Provided SlackFunction callback_id: "${callbackId}" does not have a matching manifest ` + 'definition. Please check your manifest file.\n' + `Definitions we were able to find: ${fnKeys}`; } diff --git a/src/cli/get-manifest.js b/src/cli/get-manifest.js index 0a567fbfe..272b466f8 100755 --- a/src/cli/get-manifest.js +++ b/src/cli/get-manifest.js @@ -13,4 +13,4 @@ const { getManifestData } = require('./hook-utils/manifest'); // write manifest to stdout console.log(JSON.stringify(manifest)); -}( process.cwd())); \ No newline at end of file +}(process.cwd())); \ No newline at end of file diff --git a/src/types/middleware.ts b/src/types/middleware.ts index 9f43e7ff0..771f6fb33 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -57,7 +57,7 @@ export interface Context extends StringIndexed { * * Read about custom Slack Functions @ https://api.slack.com/future/functions * */ - botAccessToken?: string; + slackFunctionBotAccessToken?: string; /** * A bot token, which starts with `xoxp-`. * This value can be used by `say` (overridden by botToken), From e2f2fd4672b6902fafd21b58f2ad0153f6b9d05e Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 30 Aug 2022 19:12:26 -0700 Subject: [PATCH 15/16] Tweak language --- src/App.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.ts b/src/App.ts index c476ae9c0..4f94566da 100644 --- a/src/App.ts +++ b/src/App.ts @@ -984,7 +984,7 @@ export default class App } /** - * Set the Bot Access Token if it exists in event payload to the context. + * Set the Slack Function Bot Access Token if it exists in event payload to the context. * * A bot_access_token will exist in any payload that has been generated * in the context of a Slack Function execution. This includes function_execution From de0b2c25cc5e482dcf3cd1b3c6f996bc113326e5 Mon Sep 17 00:00:00 2001 From: Sarah Jiang Date: Tue, 30 Aug 2022 19:16:29 -0700 Subject: [PATCH 16/16] Bump deno to major --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 3696a3e49..00d9b3799 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "url": "https://github.com/slackapi/bolt-js/issues" }, "dependencies": { - "@slack/deno-slack-sdk": "^0.2.0", + "@slack/deno-slack-sdk": "^1.0.0", "@slack/logger": "^3.0.0", "@slack/oauth": "^2.5.1", "@slack/socket-mode": "^1.3.0",