Skip to content

Commit

Permalink
Support SlackFunction and Function Localized Interactivity handling (#…
Browse files Browse the repository at this point in the history
…1567)

* Support SlackFunction and Function Localized Interactivity handling
  • Loading branch information
srajiang committed Aug 31, 2022
1 parent b22a7fd commit 1c7cff8
Show file tree
Hide file tree
Showing 16 changed files with 1,204 additions and 152 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"url": "https://github.com/slackapi/bolt-js/issues"
},
"dependencies": {
"@slack/deno-slack-sdk": "^0.1.0",
"@slack/deno-slack-sdk": "^1.0.0",
"@slack/logger": "^3.0.0",
"@slack/oauth": "^2.5.1",
"@slack/socket-mode": "^1.3.0",
Expand Down Expand Up @@ -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"
Expand Down
100 changes: 100 additions & 0 deletions src/App-slack-function.spec.ts
Original file line number Diff line number Diff line change
@@ -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<unknown> => Promise.resolve([...params]));

public stop = sinon.fake((...params: any[]): Promise<unknown> => Promise.resolve([...params]));

public async sendEvent(event: ReceiverEvent): Promise<void> {
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<typeof import('./App').default> {
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,
},
};
}
116 changes: 81 additions & 35 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -512,17 +515,6 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
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
*
Expand All @@ -548,7 +540,20 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
}

/**
* 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
*/
Expand All @@ -564,6 +569,9 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
return this;
}

/**
* Register an event listener
*/
public event<
EventType extends string = string,
MiddlewareCustomContext extends StringIndexed = StringIndexed,
Expand Down Expand Up @@ -610,20 +618,19 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
}

/**
* 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
*
* @param slackFn a main function to register
*
* */
public function(title: string, fn: Middleware<SlackEventMiddlewareArgs>): this {
// TODO: Support for multiple function listeners
const slackFn = new SlackFunction(title, fn);

public function(slackFn: SlackFunction): void {
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
*/
Expand Down Expand Up @@ -700,6 +707,9 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
] as Middleware<AnyMiddlewareArgs>[]);
}

/**
* Process a shortcut event
*/
public shortcut<
Shortcut extends SlackShortcut = SlackShortcut,
MiddlewareCustomContext extends StringIndexed = StringIndexed,
Expand Down Expand Up @@ -745,6 +755,11 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
] as Middleware<AnyMiddlewareArgs>[]);
}

/**
* 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<
Expand Down Expand Up @@ -923,6 +938,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
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 = {
Expand Down Expand Up @@ -967,9 +983,34 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
}
}

/**
* 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
* 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.
*/
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,
slackFunctionBotAccessToken,
isEnterpriseInstall,
retryNum: event.retryNum,
retryReason: event.retryReason,
Expand Down Expand Up @@ -1022,17 +1063,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
break;
}

// NOTE: the following doesn't work because... distributive?
// const listenerArgs: Partial<AnyMiddlewareArgs> = {
const listenerArgs: Pick<AnyMiddlewareArgs, 'body' | 'payload'> & {
/** 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<any>;
} = {
const listenerArgs: ListenerArgs = {
body: bodyArg,
payload,
};
Expand Down Expand Up @@ -1083,7 +1114,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
await ack();
}

// Get the client arg
// Get or create the client
let { client } = this;
const token = selectToken(context);

Expand Down Expand Up @@ -1589,9 +1620,11 @@ function isBlockActionOrInteractiveMessageBody(
return (body as SlackActionMiddlewareArgs<BlockAction | InteractiveMessage>['body']).actions !== undefined;
}

// Returns either a bot token or a 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.botToken !== undefined ? context.botToken : context.userToken;
return context.slackFunctionBotAccessToken ?? context.botToken ?? context.userToken;
}

function buildRespondFn(
Expand All @@ -1615,3 +1648,16 @@ function isEventTypeToSkipAuthorize(eventType: string) {
// Instrumentation
// Don't change the position of the following code
addAppMetadata({ name: packageJson.name, version: packageJson.version });

// types
type ListenerArgs = Pick<AnyMiddlewareArgs, 'body' | 'payload'> & {
/** 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<any>;
/* Complete might be set below */
complete?: CompleteFunction;
};

0 comments on commit 1c7cff8

Please sign in to comment.