diff --git a/src/App.spec.ts b/src/App.spec.ts index cee1049f5..9975b4dea 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -10,6 +10,7 @@ import { Receiver, ReceiverEvent, SayFn, NextMiddleware } from './types'; import { ConversationStore } from './conversation-store'; import { LogLevel } from '@slack/logger'; import { ViewConstraints } from './App'; +import { WebClientOptions, WebClient } from '@slack/web-api'; describe('App', () => { describe('constructor', () => { @@ -612,6 +613,127 @@ describe('App', () => { }); }); + describe('logger', () => { + + it('should be available in middleware/listener args', async () => { + // Arrange + const App = await importApp(overrides); // tslint:disable-line:variable-name + const fakeLogger = createFakeLogger(); + const app = new App({ + logger: fakeLogger, + receiver: fakeReceiver, + authorize: sinon.fake.resolves(dummyAuthorizationResult), + }); + app.use(({ logger, body }) => { + logger.info(body); + }); + + app.event('app_home_opened', ({ logger, event }) => { + logger.debug(event); + }); + + const receiverEvents = [ + { + body: { + type: 'event_callback', + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_home_opened', + event_ts: '1234567890.123456', + user: 'UXXXXXXX1', + text: 'hello friends!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noop, + }, + ]; + + // Act + receiverEvents.forEach(event => fakeReceiver.emit('message', event)); + await delay(); + + // Assert + assert.isTrue(fakeLogger.info.called); + assert.isTrue(fakeLogger.debug.called); + }); + }); + + describe('client', () => { + + it('should be available in middleware/listener args', async () => { + // Arrange + const App = await importApp(mergeOverrides( // tslint:disable-line:variable-name + withNoopAppMetadata(), + withSuccessfulBotUserFetchingWebClient('B123', 'U123'), + )); + const tokens = [ + 'xoxb-123', + 'xoxp-456', + 'xoxb-123', + ]; + const app = new App({ + receiver: fakeReceiver, + authorize: () => { + const token = tokens.pop(); + if (typeof token === 'undefined') { + return Promise.resolve({ botId: 'B123' }); + } + if (token.startsWith('xoxb-')) { + return Promise.resolve({ botToken: token, botId: 'B123' }); + } + return Promise.resolve({ userToken: token, botId: 'B123' }); + }, + }); + app.use(async ({ client }) => { + await client.auth.test(); + }); + const clients: WebClient[] = []; + app.event('app_home_opened', async ({ client }) => { + clients.push(client); + await client.auth.test(); + }); + + const event = { + body: { + type: 'event_callback', + token: 'legacy', + team_id: 'T123', + api_app_id: 'A123', + event: { + type: 'app_home_opened', + event_ts: '123.123', + user: 'U123', + text: 'Hi there!', + tab: 'home', + view: {}, + }, + }, + respond: noop, + ack: noop, + }; + const receiverEvents = [event, event, event]; + + // Act + receiverEvents.forEach(event => fakeReceiver.emit('message', event)); + await delay(); + + // Assert + assert.isUndefined(app.client.token); + + assert.equal(clients[0].token, 'xoxb-123'); + assert.equal(clients[1].token, 'xoxp-456'); + assert.equal(clients[2].token, 'xoxb-123'); + + assert.notEqual(clients[0], clients[1]); + assert.strictEqual(clients[0], clients[2]); + }); + }); + describe('say()', () => { function createChannelContextualReceiverEvents(channelId: string): ReceiverEvent[] { @@ -849,6 +971,10 @@ function withSuccessfulBotUserFetchingWebClient(botId: string, botUserId: string return { '@slack/web-api': { WebClient: class { + public token?: string; + constructor(token?: string, _options?: WebClientOptions) { + this.token = token; + } public auth = { test: sinon.fake.resolves({ user_id: botUserId }), }; diff --git a/src/App.ts b/src/App.ts index 63aa79595..4d7f80238 100644 --- a/src/App.ts +++ b/src/App.ts @@ -4,7 +4,6 @@ import { ChatPostMessageArguments, addAppMetadata, WebClientOptions, - WebAPICallResult, } from '@slack/web-api'; import { Logger, LogLevel, ConsoleLogger } from '@slack/logger'; import axios, { AxiosInstance } from 'axios'; @@ -110,6 +109,19 @@ export interface ErrorHandler { (error: CodedError): void; } +class WebClientPool { + private pool: { [token: string]: WebClient } = {}; + public getOrCreate(token: string, clientOptions: WebClientOptions): WebClient { + const cachedClient = this.pool[token]; + if (typeof cachedClient !== 'undefined') { + return cachedClient; + } + const client = new WebClient(token, clientOptions); + this.pool[token] = client; + return client; + } +} + /** * A Slack App */ @@ -118,6 +130,10 @@ export default class App { /** Slack Web API client */ public client: WebClient; + private clientOptions: WebClientOptions; + + private clients: { [teamId: string]: WebClientPool } = {}; + /** Receiver - ingests events from the Slack platform */ private receiver: Receiver; @@ -158,13 +174,15 @@ export default class App { this.logger.setLevel(logLevel); this.errorHandler = defaultErrorHandler(this.logger); - this.client = new WebClient(undefined, { + this.clientOptions = { agent, logLevel, logger, tls: clientTls, slackApiUrl: clientOptions !== undefined ? clientOptions.slackApiUrl : undefined, - }); + }; + // the public WebClient instance (app.client) - this one doesn't have a token + this.client = new WebClient(undefined, this.clientOptions); this.axios = axios.create(Object.assign( { @@ -284,9 +302,9 @@ export default class App { // NOTE: this is what's called a convenience generic, so that types flow more easily without casting. // https://basarat.gitbooks.io/typescript/docs/types/generics.html#design-pattern-convenience-generic public action( - actionId: string | RegExp, - ...listeners: Middleware>[] - ): void; + actionId: string | RegExp, + ...listeners: Middleware>[] + ): void; public action = ActionConstraints>( constraints: Constraints, @@ -299,7 +317,7 @@ export default class App { ...listeners: Middleware>>[] ): void { // Normalize Constraints - const constraints: ActionConstraints = + const constraints: ActionConstraints = (typeof actionIdOrConstraints === 'string' || util.types.isRegExp(actionIdOrConstraints)) ? { action_id: actionIdOrConstraints } : actionIdOrConstraints; @@ -418,8 +436,8 @@ export default class App { // Factory for say() utility const createSay = (channelId: string): SayFn => { - const token = context.botToken !== undefined ? context.botToken : context.userToken; - return (message: Parameters[0]): Promise => { + const token = selectToken(context); + return (message: Parameters[0]) => { const postMessageArguments: ChatPostMessageArguments = (typeof message === 'string') ? { token, text: message, channel: channelId } : { ...message, token, channel: channelId }; @@ -427,6 +445,16 @@ export default class App { }; }; + let listenerArgClient = this.client; + const token = selectToken(context); + if (typeof token !== 'undefined') { + let pool = this.clients[source.teamId]; + if (typeof pool === 'undefined') { + pool = this.clients[source.teamId] = new WebClientPool(); + } + listenerArgClient = pool.getOrCreate(token, this.clientOptions); + } + // Set body and payload (this value will eventually conform to AnyMiddlewareArgs) // NOTE: the following doesn't work because... distributive? // const listenerArgs: Partial = { @@ -437,7 +465,13 @@ export default class App { respond?: RespondFn, /** Ack function might be set below */ ack?: AckFn, + /** The logger for this Bolt app */ + logger?: Logger, + /** WebClient with token */ + client?: WebClient, } = { + logger: this.logger, + client: listenerArgClient, body: bodyArg, payload: (type === IncomingEventType.Event) ? @@ -516,6 +550,8 @@ export default class App { startGlobalBubble(error); }, globalProcessedContext, + this.logger, + listenerArgClient, ); }); }, @@ -525,6 +561,8 @@ export default class App { } }, context, + this.logger, + listenerArgClient, ); } @@ -613,6 +651,10 @@ function singleTeamAuthorization( }; } +function selectToken(context: Context): string | undefined { + return context.botToken !== undefined ? context.botToken : context.userToken; +} + /* Instrumentation */ addAppMetadata({ name: packageJson.name, version: packageJson.version }); diff --git a/src/conversation-store.spec.ts b/src/conversation-store.spec.ts index e166909c6..f8abfe9b8 100644 --- a/src/conversation-store.spec.ts +++ b/src/conversation-store.spec.ts @@ -6,6 +6,8 @@ import { Override, createFakeLogger, delay, wrapToResolveOnFirstCall } from './t import rewiremock from 'rewiremock'; import { ConversationStore } from './conversation-store'; import { AnyMiddlewareArgs, NextMiddleware, Context } from './types'; +import { WebClient } from '@slack/web-api'; +import { Logger } from '@slack/logger'; describe('conversationContext middleware', () => { it('should forward events that have no conversation ID', async () => { @@ -19,7 +21,11 @@ describe('conversationContext middleware', () => { const { conversationContext } = await importConversationStore( withGetTypeAndConversation(fakeGetTypeAndConversation), ); - const fakeArgs = { body: {}, context: dummyContext, next: fakeNext } as unknown as MiddlewareArgs; + const fakeArgs = { + body: {}, + context: dummyContext, + next: fakeNext, + } as unknown as MiddlewareArgs; // Act const middleware = conversationContext(fakeStore, fakeLogger); @@ -186,7 +192,12 @@ describe('MemoryStore', () => { /* Testing Harness */ -type MiddlewareArgs = AnyMiddlewareArgs & { next: NextMiddleware, context: Context }; +type MiddlewareArgs = AnyMiddlewareArgs & { + next: NextMiddleware, + context: Context, + logger: Logger, + client: WebClient, +}; interface DummyContext { conversation?: ConversationState; diff --git a/src/middleware/builtin.spec.ts b/src/middleware/builtin.spec.ts index a8a40ca9d..575d95750 100644 --- a/src/middleware/builtin.spec.ts +++ b/src/middleware/builtin.spec.ts @@ -3,7 +3,7 @@ import 'mocha'; import { assert } from 'chai'; import sinon from 'sinon'; import { ErrorCode } from '../errors'; -import { Override, delay, wrapToResolveOnFirstCall } from '../test-helpers'; +import { Override, delay, wrapToResolveOnFirstCall, createFakeLogger } from '../test-helpers'; import rewiremock from 'rewiremock'; import { SlackEventMiddlewareArgs, @@ -16,6 +16,8 @@ import { import { onlyCommands, onlyEvents, matchCommandName, matchEventType, subtype } from './builtin'; import { SlashCommand } from '../types/command/index'; import { SlackEvent, AppMentionEvent, BotMessageEvent } from '../types/events/base-events'; +import { WebClient } from '@slack/web-api'; +import { Logger } from '@slack/logger'; describe('matchMessage()', () => { function initializeTestCase(pattern: string | RegExp): Mocha.AsyncFunc { @@ -433,11 +435,15 @@ describe('ignoreSelf()', () => { }); describe('onlyCommands', () => { + const logger = createFakeLogger(); + const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); it('should detect valid requests', async () => { const payload: SlashCommand = { ...validCommandPayload }; const fakeNext = sinon.fake(); onlyCommands({ + logger, + client, payload, command: payload, body: payload, @@ -454,6 +460,8 @@ describe('onlyCommands', () => { const payload: any = {}; const fakeNext = sinon.fake(); onlyCommands({ + logger, + client, payload, action: payload, command: undefined, @@ -469,10 +477,15 @@ describe('onlyCommands', () => { }); describe('matchCommandName', () => { - function buildArgs(fakeNext: NextMiddleware): SlackCommandMiddlewareArgs & { next: any, context: any } { + const logger = createFakeLogger(); + const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); + + function buildArgs(fakeNext: NextMiddleware): SlackCommandMiddlewareArgs & MiddlewareCommonArgs { const payload: SlashCommand = { ...validCommandPayload }; return { payload, + logger, + client, command: payload, body: payload, say: sayNoop, @@ -498,6 +511,9 @@ describe('matchCommandName', () => { describe('onlyEvents', () => { + const logger = createFakeLogger(); + const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); + it('should detect valid requests', async () => { const fakeNext = sinon.fake(); const args: SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } = { @@ -516,7 +532,13 @@ describe('onlyEvents', () => { }, say: sayNoop, }; - onlyEvents({ next: fakeNext, context: {}, ...args }); + onlyEvents({ + logger, + client, + next: fakeNext, + context: {}, + ...args, + }); assert.isTrue(fakeNext.called); }); @@ -524,6 +546,8 @@ describe('onlyEvents', () => { const payload: SlashCommand = { ...validCommandPayload }; const fakeNext = sinon.fake(); onlyEvents({ + logger, + client, payload, command: payload, body: payload, @@ -538,6 +562,9 @@ describe('onlyEvents', () => { }); describe('matchEventType', () => { + const logger = createFakeLogger(); + const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); + function buildArgs(): SlackEventMiddlewareArgs<'app_mention'> & { event?: SlackEvent } { return { payload: appMentionEvent, @@ -559,18 +586,33 @@ describe('matchEventType', () => { it('should detect valid requests', async () => { const fakeNext = sinon.fake(); - matchEventType('app_mention')({ next: fakeNext, context: {}, ...buildArgs() }); + matchEventType('app_mention')({ + logger, + client, + next: fakeNext, + context: {}, + ...buildArgs(), + }); assert.isTrue(fakeNext.called); }); it('should skip other requests', async () => { const fakeNext = sinon.fake(); - matchEventType('app_home_opened')({ next: fakeNext, context: {}, ...buildArgs() }); + matchEventType('app_home_opened')({ + logger, + client, + next: fakeNext, + context: {}, + ...buildArgs(), + }); assert.isFalse(fakeNext.called); }); }); describe('subtype', () => { + const logger = createFakeLogger(); + const client = new WebClient(undefined, { logger, slackApiUrl: undefined }); + function buildArgs(): SlackEventMiddlewareArgs<'message'> & { event?: SlackEvent } { return { payload: botMessageEvent, @@ -592,13 +634,25 @@ describe('subtype', () => { it('should detect valid requests', async () => { const fakeNext = sinon.fake(); - subtype('bot_message')({ next: fakeNext, context: {}, ...buildArgs() }); + subtype('bot_message')({ + logger, + client, + next: fakeNext, + context: {}, + ...buildArgs(), + }); assert.isTrue(fakeNext.called); }); it('should skip other requests', async () => { const fakeNext = sinon.fake(); - subtype('me_message')({ next: fakeNext, context: {}, ...buildArgs() }); + subtype('me_message')({ + logger, + client, + next: fakeNext, + context: {}, + ...buildArgs(), + }); assert.isFalse(fakeNext.called); }); }); @@ -609,14 +663,19 @@ interface DummyContext { matches?: RegExpExecArray; } -type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & { next: NextMiddleware, context: Context }; -type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> - & { next: NextMiddleware, context: Context }; +interface MiddlewareCommonArgs { + next: NextMiddleware; + context: Context; + logger: Logger; + client: WebClient; +} +type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & MiddlewareCommonArgs; +type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> & MiddlewareCommonArgs; type MemberJoinedOrLeftChannelMiddlewareArgs = SlackEventMiddlewareArgs<'member_joined_channel' | 'member_left_channel'> - & { next: NextMiddleware, context: Context }; + & MiddlewareCommonArgs; -type CommandMiddlewareArgs = SlackCommandMiddlewareArgs & { next: NextMiddleware; context: Context }; +type CommandMiddlewareArgs = SlackCommandMiddlewareArgs & MiddlewareCommonArgs; async function importBuiltin( overrides: Override = {}, diff --git a/src/middleware/process.ts b/src/middleware/process.ts index 75b97d013..916a61dea 100644 --- a/src/middleware/process.ts +++ b/src/middleware/process.ts @@ -5,6 +5,8 @@ import { NextMiddleware, PostProcessFn, } from '../types'; +import { WebClient } from '@slack/web-api'; +import { Logger } from '@slack/logger'; // TODO: what happens if an error is thrown inside a middleware/listener function? it should propagate up and eventually // be dealt with by the global error handler @@ -14,6 +16,8 @@ export function processMiddleware( afterMiddleware: (context: Context, args: AnyMiddlewareArgs, startBubble: (error?: Error) => void) => void, afterPostProcess: (error?: Error) => void, context: Context = {}, + logger: Logger, + client: WebClient, ): void { // Generate next() @@ -33,7 +37,7 @@ export function processMiddleware( // In this condition, errorOrPostProcess will be a postProcess function or undefined postProcessFns[middlewareIndex - 1] = errorOrPostProcess === undefined ? noopPostProcess : errorOrPostProcess; - thisMiddleware({ context, next: nextWhenNotLast, ...initialArguments }); + thisMiddleware({ context, logger, client, next: nextWhenNotLast, ...initialArguments }); if (isLastMiddleware) { postProcessFns[middlewareIndex] = noopPostProcess; @@ -75,7 +79,7 @@ export function processMiddleware( }; const firstMiddleware = middleware[0]; - firstMiddleware({ context, next, ...initialArguments }); + firstMiddleware({ context, logger, client, next, ...initialArguments }); } function noop(): void { } // tslint:disable-line:no-empty diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index a60009495..5ecb9cfd9 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -417,6 +417,26 @@ export interface IMOpenEvent extends StringIndexed { channel: string; } +export interface InviteRequestedEvent extends StringIndexed { + type: 'invite_requested'; + invite_request: { + id: string; + email: string; + date_created: number; + requester_ids: string[]; + channel_ids: string[]; + invite_type: 'restricted' | 'ultra_restricted' | 'full_member'; + real_name: string; + date_expire: number; + request_reason: string; + team: { + id: string; + name: string; + domain: string; + } + }; +} + export interface LinkSharedEvent extends StringIndexed { type: 'link_shared'; channel: string; diff --git a/src/types/middleware.ts b/src/types/middleware.ts index f6f195d00..ff7954f38 100644 --- a/src/types/middleware.ts +++ b/src/types/middleware.ts @@ -5,6 +5,8 @@ import { SlackCommandMiddlewareArgs } from './command'; import { SlackOptionsMiddlewareArgs } from './options'; import { SlackViewMiddlewareArgs } from './view'; import { CodedError, ErrorCode } from '../errors'; +import { WebClient } from '@slack/web-api'; +import { Logger } from '@slack/logger'; export type AnyMiddlewareArgs = SlackEventMiddlewareArgs | SlackActionMiddlewareArgs | SlackCommandMiddlewareArgs | @@ -21,7 +23,12 @@ export interface Context extends StringIndexed { // constraint would mess up the interface of App#event(), App#message(), etc. export interface Middleware { // TODO: is there something nice we can do to get context's property types to flow from one middleware to the next? - (args: Args & { next: NextMiddleware, context: Context }): unknown; + (args: Args & { + next: NextMiddleware, + context: Context, + logger: Logger, + client: WebClient, + }): unknown; } export interface NextMiddleware {