Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for remote functions #2026

Merged
merged 17 commits into from
Aug 14, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Incorporate PR comments
misscoded committed Jan 25, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature. The key has expired.
commit 1b7987369894557e04028f5f98d1aaa974a3e3cd
45 changes: 17 additions & 28 deletions src/App.ts
Original file line number Diff line number Diff line change
@@ -61,7 +61,7 @@ import { AllMiddlewareArgs, contextBuiltinKeys } from './types/middleware';
import { StringIndexed } from './types/helpers';
// eslint-disable-next-line import/order
import allSettled = require('promise.allsettled'); // eslint-disable-line @typescript-eslint/no-require-imports
import { FunctionCompleteFn, FunctionFailFn, WorkflowFunction, WorkflowFunctionMiddleware } from './WorkflowFunction';
import { FunctionCompleteFn, FunctionFailFn, CustomFunction, CustomFunctionMiddleware } from './CustomFunction';
// eslint-disable-next-line @typescript-eslint/no-require-imports, import/no-commonjs
const packageJson = require('../package.json'); // eslint-disable-line @typescript-eslint/no-var-requires

@@ -337,7 +337,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
this.errorHandler = defaultErrorHandler(this.logger) as AnyErrorHandler;
this.extendedErrorHandler = extendedErrorHandler;

// Override token with functionBotToken in function-related handlers
// Override token with functionBotAccessToken in function-related handlers
this.attachFunctionToken = attachFunctionToken;

/* ------------------------ Set client options ------------------------*/
@@ -526,10 +526,10 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
}

/**
* Register WorkflowFunction middleware
* Register CustomFunction middleware
*/
public function(callbackId: string, ...listeners: WorkflowFunctionMiddleware): this {
const fn = new WorkflowFunction(callbackId, listeners);
public function(callbackId: string, ...listeners: CustomFunctionMiddleware): this {
const fn = new CustomFunction(callbackId, listeners);
const m = fn.getMiddleware();
this.middleware.push(m);
return this;
@@ -964,10 +964,12 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
retryReason: event.retryReason,
};

// Extract function-related information and augment to context
const { functionExecutionId, functionBotAccessToken } = extractFunctionContext(body);
if (functionExecutionId) { context.functionExecutionId = functionExecutionId; }

if (this.attachFunctionToken) {
const { functionExecutionId, functionBotToken } = extractFunctionContext(body);
if (functionExecutionId) { context.functionExecutionId = functionExecutionId; }
if (functionBotToken) { context.functionBotToken = functionBotToken; }
if (functionBotAccessToken) { context.functionBotAccessToken = functionBotAccessToken; }
}

// Factory for say() utility
@@ -1082,23 +1084,10 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
let { client } = this;
const token = selectToken(context);

// TODO :: WorkflowFunction owns these same utilities. Rework TS to allow for reuse.
// Set complete() and fail() utilities for function-related interactivity
// Add complete() and fail() utilities for function-related interactivity
if (type === IncomingEventType.Action && context.functionExecutionId !== undefined) {
listenerArgs.complete = (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({
token: context.functionBotToken,
outputs: params.outputs || {},
function_execution_id: context.functionExecutionId,
});
listenerArgs.fail = (params: Parameters<FunctionFailFn>[0]) => {
const { error } = params ?? {};

return client.functions.completeError({
token: context.functionBotToken,
error,
function_execution_id: context.functionExecutionId,
});
};
listenerArgs.complete = CustomFunction.createFunctionComplete(context, client);
listenerArgs.fail = CustomFunction.createFunctionFail(context, client);
}

if (token !== undefined) {
@@ -1609,21 +1598,21 @@ function escapeHtml(input: string | undefined | null): string {

function extractFunctionContext(body: StringIndexed) {
let functionExecutionId;
let functionBotToken;
let functionBotAccessToken;

// function_executed event
if (body.event && body.event.type === 'function_executed' && body.event.function_execution_id) {
functionExecutionId = body.event.function_execution_id;
functionBotToken = body.event.bot_access_token;
functionBotAccessToken = body.event.bot_access_token;
}

// interactivity (block_actions)
if (body.function_data) {
functionExecutionId = body.function_data.execution_id;
functionBotToken = body.bot_access_token;
functionBotAccessToken = body.bot_access_token;
}

return { functionExecutionId, functionBotToken };
return { functionExecutionId, functionBotAccessToken };
}

// ----------------------------
142 changes: 65 additions & 77 deletions src/WorkflowFunction.ts → src/CustomFunction.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
WebClient,
FunctionsCompleteErrorResponse,

Check failure on line 4 in src/CustomFunction.ts

GitHub Actions / build (12.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteErrorResponse'.

Check failure on line 4 in src/CustomFunction.ts

GitHub Actions / build (14.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteErrorResponse'.

Check failure on line 4 in src/CustomFunction.ts

GitHub Actions / build (16.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteErrorResponse'.

Check failure on line 4 in src/CustomFunction.ts

GitHub Actions / build (18.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteErrorResponse'.

Check failure on line 4 in src/CustomFunction.ts

GitHub Actions / build (20.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteErrorResponse'.
FunctionsCompleteSuccessResponse,

Check failure on line 5 in src/CustomFunction.ts

GitHub Actions / build (12.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteSuccessResponse'.

Check failure on line 5 in src/CustomFunction.ts

GitHub Actions / build (14.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteSuccessResponse'.

Check failure on line 5 in src/CustomFunction.ts

GitHub Actions / build (16.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteSuccessResponse'.

Check failure on line 5 in src/CustomFunction.ts

GitHub Actions / build (18.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteSuccessResponse'.

Check failure on line 5 in src/CustomFunction.ts

GitHub Actions / build (20.x)

Module '"@slack/web-api"' has no exported member 'FunctionsCompleteSuccessResponse'.
} from '@slack/web-api';
import {
Middleware,
@@ -12,59 +12,59 @@
Context,
} from './types';
import processMiddleware from './middleware/process';
import { WorkflowFunctionInitializationError } from './errors';
import { CustomFunctionInitializationError } from './errors';

/** Interfaces */

export interface FunctionCompleteArguments {
interface FunctionCompleteArguments {
outputs?: {
[key: string]: any;
};
}

export interface FunctionFailArguments {
error: string;
}

export interface FunctionCompleteFn {
(params?: FunctionCompleteArguments): Promise<FunctionsCompleteSuccessResponse>;
}

interface FunctionFailArguments {
error: string;
}

export interface FunctionFailFn {
(params: FunctionFailArguments): Promise<FunctionsCompleteErrorResponse>;
}

export interface WorkflowFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> {
export interface CustomFunctionExecuteMiddlewareArgs extends SlackEventMiddlewareArgs<'function_executed'> {
complete: FunctionCompleteFn;
fail: FunctionFailFn;
}

/** Types */

export type SlackWorkflowFunctionMiddlewareArgs = WorkflowFunctionExecuteMiddlewareArgs;
export type SlackCustomFunctionMiddlewareArgs = CustomFunctionExecuteMiddlewareArgs;

export type WorkflowFunctionExecuteMiddleware = Middleware<WorkflowFunctionExecuteMiddlewareArgs>;
type CustomFunctionExecuteMiddleware = Middleware<CustomFunctionExecuteMiddlewareArgs>;

export type WorkflowFunctionMiddleware = WorkflowFunctionExecuteMiddleware[];
export type CustomFunctionMiddleware = CustomFunctionExecuteMiddleware[];

export type AllWorkflowFunctionMiddlewareArgs
<T extends SlackWorkflowFunctionMiddlewareArgs = SlackWorkflowFunctionMiddlewareArgs> = T & AllMiddlewareArgs;
export type AllCustomFunctionMiddlewareArgs
<T extends SlackCustomFunctionMiddlewareArgs = SlackCustomFunctionMiddlewareArgs> = T & AllMiddlewareArgs;

/** Constants */

const VALID_PAYLOAD_TYPES = new Set(['function_executed']);

/** Class */

export class WorkflowFunction {
export class CustomFunction {
/** Function callback_id */
public callbackId: string;

private middleware: WorkflowFunctionMiddleware;
private middleware: CustomFunctionMiddleware;

public constructor(
callbackId: string,
middleware: WorkflowFunctionMiddleware,
middleware: CustomFunctionMiddleware,
) {
validate(callbackId, middleware);

@@ -81,35 +81,69 @@
};
}

private matchesConstraints(args: SlackWorkflowFunctionMiddlewareArgs): boolean {
private matchesConstraints(args: SlackCustomFunctionMiddlewareArgs): boolean {
return args.payload.function.callback_id === this.callbackId;
}

private async processEvent(args: AllWorkflowFunctionMiddlewareArgs): Promise<void> {
private async processEvent(args: AllCustomFunctionMiddlewareArgs): Promise<void> {
const functionArgs = prepareFunctionArgs(args);
const stepMiddleware = this.getStepMiddleware();
return processStepMiddleware(functionArgs, stepMiddleware);
}

private getStepMiddleware(): WorkflowFunctionMiddleware {
private getStepMiddleware(): CustomFunctionMiddleware {
return this.middleware;
}

/**
* Factory for `complete()` utility
* @param args function_executed event
*/
public static createFunctionComplete(context: Context, client: WebClient): FunctionCompleteFn {
const token = selectToken(context);
const { functionExecutionId } = context;

return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({

Check failure on line 106 in src/CustomFunction.ts

GitHub Actions / build (12.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 106 in src/CustomFunction.ts

GitHub Actions / build (14.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 106 in src/CustomFunction.ts

GitHub Actions / build (16.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 106 in src/CustomFunction.ts

GitHub Actions / build (18.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 106 in src/CustomFunction.ts

GitHub Actions / build (20.x)

Property 'functions' does not exist on type 'WebClient'.
token,
outputs: params.outputs || {},
function_execution_id: functionExecutionId,
});
}

/**
* Factory for `fail()` utility
* @param args function_executed event
*/
public static createFunctionFail(context: Context, client: WebClient): FunctionFailFn {
const token = selectToken(context);

return (params: Parameters<FunctionFailFn>[0]) => {
const { error } = params ?? {};
const { functionExecutionId } = context;

return client.functions.completeError({

Check failure on line 124 in src/CustomFunction.ts

GitHub Actions / build (12.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 124 in src/CustomFunction.ts

GitHub Actions / build (14.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 124 in src/CustomFunction.ts

GitHub Actions / build (16.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 124 in src/CustomFunction.ts

GitHub Actions / build (18.x)

Property 'functions' does not exist on type 'WebClient'.

Check failure on line 124 in src/CustomFunction.ts

GitHub Actions / build (20.x)

Property 'functions' does not exist on type 'WebClient'.
token,
error,
function_execution_id: functionExecutionId,
});
};
}
}

/** Helper Functions */

export function validate(callbackId: string, listeners: WorkflowFunctionMiddleware): void {
export function validate(callbackId: string, listeners: CustomFunctionMiddleware): void {
// Ensure callbackId is valid
if (typeof callbackId !== 'string') {
const errorMsg = 'WorkflowFunction expects a callback_id as the first argument';
throw new WorkflowFunctionInitializationError(errorMsg);
const errorMsg = 'CustomFunction expects a callback_id as the first argument';
throw new CustomFunctionInitializationError(errorMsg);
}

// Ensure all listeners are functions
listeners.forEach((listener) => {
if (!(listener instanceof Function)) {
const errorMsg = 'All WorkflowFunction listeners must be functions';
throw new WorkflowFunctionInitializationError(errorMsg);
const errorMsg = 'All CustomFunction listeners must be functions';
throw new CustomFunctionInitializationError(errorMsg);
}
});
}
@@ -119,8 +153,8 @@
* @param args workflow_step_edit action
*/
export async function processStepMiddleware(
args: AllWorkflowFunctionMiddlewareArgs,
middleware: WorkflowFunctionMiddleware,
args: AllCustomFunctionMiddlewareArgs,
middleware: CustomFunctionMiddleware,
): Promise<void> {
const { context, client, logger } = args;
const callbacks = [...middleware] as Middleware<AnyMiddlewareArgs>[];
@@ -134,59 +168,13 @@
}
}

export function isFunctionEvent(args: AnyMiddlewareArgs): args is AllWorkflowFunctionMiddlewareArgs {
function isFunctionEvent(args: AnyMiddlewareArgs): args is AllCustomFunctionMiddlewareArgs {
return VALID_PAYLOAD_TYPES.has(args.payload.type);
}

function selectToken(context: Context): string | undefined {
// If attachFunctionToken = false, fallback to botToken or userToken
return context.functionBotToken ? context.functionBotToken : context.botToken || context.userToken;
}

/**
* Factory for `complete()` utility
* @param args function_executed event
*/
export function createFunctionComplete(
args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>,
): FunctionCompleteFn {
const {
context,
client,
payload: { function_execution_id },
} = args;
const token = selectToken(context);

return (params: Parameters<FunctionCompleteFn>[0] = {}) => client.functions.completeSuccess({
token,
outputs: params.outputs || {},
function_execution_id,
});
}

/**
* Factory for `fail()` utility
* @param args function_executed event
*/
export function createFunctionFail(
args: AllWorkflowFunctionMiddlewareArgs<WorkflowFunctionExecuteMiddlewareArgs>,
): FunctionFailFn {
const {
context,
client,
payload: { function_execution_id },
} = args;
const token = selectToken(context);

return (params: Parameters<FunctionFailFn>[0]) => {
const { error } = params ?? {};

return client.functions.completeError({
token,
error,
function_execution_id,
});
};
return context.functionBotAccessToken ? context.functionBotAccessToken : context.botToken || context.userToken;
}

/**
@@ -195,20 +183,20 @@
* - events will *not* continue down global middleware chain to subsequent listeners
* 2. augments args with step lifecycle-specific properties/utilities
* */
export function prepareFunctionArgs(args: any): AllWorkflowFunctionMiddlewareArgs {
function prepareFunctionArgs(args: any): AllCustomFunctionMiddlewareArgs {
const { next: _next, ...functionArgs } = args;
const preparedArgs: any = { ...functionArgs };
const token = selectToken(functionArgs.context);

// Making calls with a functionBotToken establishes continuity between
// Making calls with a functionBotAccessToken establishes continuity between
// a function_executed event and subsequent interactive events (actions)
const client = new WebClient(token, { ...functionArgs.client });
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the first time to see this way to initialize a new instance. Other code in bolt-js uses clientOptions for the second argument instead. I haven't verified on my end yet but have you confirmed this code can make the same effect with new WebClient(token, clientOptions)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm 99% sure I tested this when you brought it up as a concern back in January, but we will make doubly-sure before we cut the stable release.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to take a slightly different tact here: expose the web client options as assembled in the bolt App constructor via a getter method, and simply re-use those here instead.

preparedArgs.client = client;

// Utility args
preparedArgs.inputs = preparedArgs.event.inputs;
preparedArgs.complete = createFunctionComplete(preparedArgs);
preparedArgs.fail = createFunctionFail(preparedArgs);
preparedArgs.complete = CustomFunction.createFunctionComplete(preparedArgs.context, client);
preparedArgs.fail = CustomFunction.createFunctionFail(preparedArgs.context, client);

return preparedArgs;
}
6 changes: 3 additions & 3 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -35,7 +35,7 @@ export enum ErrorCode {

WorkflowStepInitializationError = 'slack_bolt_workflow_step_initialization_error',

WorkflowFunctionInitializationError = 'slack_bolt_workflow_function_initialization_error',
CustomFunctionInitializationError = 'slack_bolt_workflow_function_initialization_error',
}

export class UnknownError extends Error implements CodedError {
@@ -141,6 +141,6 @@ export class WorkflowStepInitializationError extends Error implements CodedError
public code = ErrorCode.WorkflowStepInitializationError;
}

export class WorkflowFunctionInitializationError extends Error implements CodedError {
public code = ErrorCode.WorkflowFunctionInitializationError;
export class CustomFunctionInitializationError extends Error implements CodedError {
public code = ErrorCode.CustomFunctionInitializationError;
}
48 changes: 25 additions & 23 deletions src/types/events/base-events.ts
Original file line number Diff line number Diff line change
@@ -427,37 +427,39 @@ export interface FileUnsharedEvent {
}

export interface FunctionParams {
type?: string,
name?: string,
description?: string,
title?: string,
is_required?: boolean,
type?: string;
name?: string;
description?: string;
title?: string;
is_required?: boolean;
}

export interface FunctionInputValues {
export interface FunctionInputs {
[key: string]: unknown;
}

export type FunctionOutputValues = FunctionInputValues;
export type FunctionOutputValues = FunctionInputs;

// TODO :: Update this with new payload info
export interface FunctionExecutedEvent {
type: 'function_executed',
type: 'function_executed';
function: {
id: string,
callback_id: string,
title: string,
description: string,
type: string,
input_parameters: FunctionParams[],
output_parameters: FunctionParams[],
app_id: string,
date_updated: number,
},
function_execution_id: string,
inputs: FunctionInputValues,
workflow_token: string, // xwfp-xxxxxxxxxxx
event_ts: string,
id: string;
callback_id: string;
title: string;
description: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: string;
description?: string;

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Also included in pre-stable-fixes branch.

type: string;
input_parameters: FunctionParams[];
output_parameters: FunctionParams[];
app_id: string;
date_created: number;
date_updated: number;
date_deleted: number
};
inputs: FunctionInputs;
function_execution_id: string;
workflow_execution_id: string;
event_ts: string;
bot_access_token: string;
}

export interface GridMigrationFinishedEvent {