diff --git a/src/App.spec.ts b/src/App.spec.ts index af4a203ef..cb294cc13 100644 --- a/src/App.spec.ts +++ b/src/App.spec.ts @@ -432,25 +432,24 @@ describe('App', () => { team: {}, view: { callback_id: 'view_callback_id', - } + }, + }, + respond: noop, + ack: noop, + }, + { + body: { + type: 'view_closed', + channel: {}, + user: {}, + team: {}, + view: { + callback_id: 'view_callback_id', + }, }, respond: noop, ack: noop, }, - // TODO: https://github.com/slackapi/bolt/issues/263 - // { - // body: { - // type: 'view_closed', - // channel: {}, - // user: {}, - // team: {}, - // view: { - // callback_id: 'view_callback_id', - // } - // }, - // respond: noop, - // ack: noop, - // }, ]; } @@ -467,11 +466,12 @@ describe('App', () => { // Act const app = new App({ receiver: fakeReceiver, authorize: sinon.fake.resolves(dummyAuthorizationResult) }); app.use((_args) => { ackFn(); }); - app.action('block_action_id', ({ }) => { actionFn(); }) - app.action({ callback_id: 'message_action_callback_id' }, ({ }) => { actionFn(); }) - app.action({ callback_id: 'interactive_message_callback_id' }, ({ }) => { actionFn(); }) - app.action({ callback_id: 'dialog_submission_callback_id' }, ({ }) => { actionFn(); }) - app.view('view_callback_id', ({ }) => { viewFn(); }) + app.action('block_action_id', ({ }) => { actionFn(); }); + app.action({ callback_id: 'message_action_callback_id' }, ({ }) => { actionFn(); }); + app.action({ callback_id: 'interactive_message_callback_id' }, ({ }) => { actionFn(); }); + app.action({ callback_id: 'dialog_submission_callback_id' }, ({ }) => { actionFn(); }); + app.view('view_callback_id', ({ }) => { viewFn(); }); + app.view({ callback_id: 'view_callback_id', type: 'view_closed' }, ({ }) => { viewFn(); }); app.options('external_select_action_id', ({ }) => { optionsFn(); }); app.options({ callback_id: 'dialog_suggestion_callback_id' }, ({ }) => { optionsFn(); }); @@ -481,7 +481,7 @@ describe('App', () => { // Assert assert.equal(actionFn.callCount, 4); - assert.equal(viewFn.callCount, 1); + assert.equal(viewFn.callCount, 2); assert.equal(optionsFn.callCount, 2); assert.equal(ackFn.callCount, dummyReceiverEvents.length); assert(fakeErrorHandler.notCalled); diff --git a/src/App.ts b/src/App.ts index 0429a5c3c..2ce68996f 100644 --- a/src/App.ts +++ b/src/App.ts @@ -12,8 +12,7 @@ import { onlyEvents, matchEventType, matchMessage, - onlyViewSubmits, - matchCallbackId, + onlyViewActions, } from './middleware/builtin'; import { processMiddleware } from './middleware/process'; import { ConversationStore, conversationContext, MemoryStore } from './conversation-store'; @@ -33,6 +32,7 @@ import { OptionsSource, BlockAction, InteractiveMessage, + SlackViewAction, Receiver, ReceiverEvent, } from './types'; @@ -92,6 +92,11 @@ export interface ActionConstraints { callback_id?: string | RegExp; } +export interface ViewConstraints { + callback_id?: string | RegExp; + type?: 'view_closed' | 'view_submission'; +} + export interface ErrorHandler { (error: CodedError): void; } @@ -318,9 +323,39 @@ export default class App { ); } - public view(callbackId: string | RegExp, ...listeners: Middleware<SlackViewMiddlewareArgs>[]): void { + public view<ViewActionType extends SlackViewAction = SlackViewAction>( + callbackId: string | RegExp, + ...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[] + ): void; + public view<ViewActionType extends SlackViewAction = SlackViewAction>( + constraints: ViewConstraints, + ...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[] + ): void; + public view<ViewActionType extends SlackViewAction = SlackViewAction>( + callbackIdOrConstraints: string | RegExp | ViewConstraints, + ...listeners: Middleware<SlackViewMiddlewareArgs<ViewActionType>>[]): void { + const constraints: ViewConstraints = + (typeof callbackIdOrConstraints === 'string' || util.types.isRegExp(callbackIdOrConstraints)) ? + { callback_id: callbackIdOrConstraints, type: 'view_submission' } : callbackIdOrConstraints; + // Fail early if the constraints contain invalid keys + const unknownConstraintKeys = Object.keys(constraints) + .filter(k => (k !== 'callback_id' && k !== 'type')); + if (unknownConstraintKeys.length > 0) { + this.logger.error( + `View listener cannot be attached using unknown constraint keys: ${unknownConstraintKeys.join(', ')}`, + ); + return; + } + + if (constraints.type !== undefined && !validViewTypes.includes(constraints.type)) { + this.logger.error( + `View listener cannot be attached using unknown view event type: ${constraints.type}`, + ); + return; + } + this.listeners.push( - [onlyViewSubmits, matchCallbackId(callbackId), ...listeners] as Middleware<AnyMiddlewareArgs>[], + [onlyViewActions, matchConstraints(constraints), ...listeners] as Middleware<AnyMiddlewareArgs>[], ); } @@ -384,7 +419,7 @@ export default class App { payload: (type === IncomingEventType.Event) ? (bodyArg as SlackEventMiddlewareArgs['body']).event : - (type === IncomingEventType.ViewSubmitAction) ? + (type === IncomingEventType.ViewAction) ? (bodyArg as SlackViewMiddlewareArgs['body']).view : (type === IncomingEventType.Action && isBlockActionOrInteractiveMessageBody(bodyArg as SlackActionMiddlewareArgs['body'])) ? @@ -412,7 +447,7 @@ export default class App { } else if (type === IncomingEventType.Options) { const optionListenerArgs = listenerArgs as SlackOptionsMiddlewareArgs<OptionsSource>; optionListenerArgs.options = optionListenerArgs.payload; - } else if (type === IncomingEventType.ViewSubmitAction) { + } else if (type === IncomingEventType.ViewAction) { const viewListenerArgs = listenerArgs as SlackViewMiddlewareArgs; viewListenerArgs.view = viewListenerArgs.payload; } @@ -476,6 +511,8 @@ export default class App { const tokenUsage = 'Apps used in one workspace should be initialized with a token. Apps used in many workspaces ' + 'should be initialized with a authorize.'; +const validViewTypes = ['view_closed', 'view_submission']; + /** * Helper which builds the data structure the authorize hook uses to provide tokens for the context. */ @@ -491,11 +528,11 @@ function buildSource( const source: AuthorizeSourceData = { teamId: ((type === IncomingEventType.Event || type === IncomingEventType.Command) ? (body as (SlackEventMiddlewareArgs | SlackCommandMiddlewareArgs)['body']).team_id as string : - (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.id as string : + (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.id as string : assertNever(type)), enterpriseId: ((type === IncomingEventType.Event || type === IncomingEventType.Command) ? (body as (SlackEventMiddlewareArgs | SlackCommandMiddlewareArgs)['body']).enterprise_id as string : - (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.enterprise_id as string : + (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).team.enterprise_id as string : undefined), userId: ((type === IncomingEventType.Event) ? @@ -504,7 +541,7 @@ function buildSource( ((body as SlackEventMiddlewareArgs['body']).event.channel !== undefined && (body as SlackEventMiddlewareArgs['body']).event.channel.creator !== undefined) ? (body as SlackEventMiddlewareArgs['body']).event.channel.creator as string : ((body as SlackEventMiddlewareArgs['body']).event.subteam !== undefined && (body as SlackEventMiddlewareArgs['body']).event.subteam.created_by !== undefined) ? (body as SlackEventMiddlewareArgs['body']).event.subteam.created_by as string : undefined) : - (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewSubmitAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).user.id as string : + (type === IncomingEventType.Action || type === IncomingEventType.Options || type === IncomingEventType.ViewAction) ? (body as (SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs)['body']).user.id as string : (type === IncomingEventType.Command) ? (body as SlackCommandMiddlewareArgs['body']).user_id as string : undefined), conversationId: channelId, diff --git a/src/ExpressReceiver.ts b/src/ExpressReceiver.ts index c6bdc2ba7..1b036d6a5 100644 --- a/src/ExpressReceiver.ts +++ b/src/ExpressReceiver.ts @@ -270,7 +270,7 @@ function parseRequestBody( // Parse this body anyway return JSON.parse(stringBody); } catch (e) { - logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`) + logger.error(`Failed to parse body as JSON data for content-type: ${contentType}`); throw e; } } diff --git a/src/helpers.spec.ts b/src/helpers.spec.ts index eb0038f18..ad27217ac 100644 --- a/src/helpers.spec.ts +++ b/src/helpers.spec.ts @@ -77,6 +77,21 @@ describe('getTypeAndConversation()', () => { }); }); + describe('view types', () => { + // Arrange + const dummyViewBodies = createFakeViews(); + + dummyViewBodies.forEach((viewBody) => { + it(`should find Action type for ${viewBody.type}`, () => { + // Act + const typeAndConversation = getTypeAndConversation(viewBody); + + // Assert + assert(typeAndConversation.type === IncomingEventType.ViewAction); + }); + }); + }); + describe('invalid events', () => { // Arrange const fakeEventBody = { @@ -150,3 +165,18 @@ function createFakeOptions(conversationId: string): any[] { }, ]; } + +function createFakeViews(): any[] { + return [ + // Body for a view_submission event + { + type: 'view_submission', + view: { id: 'V123' }, + }, + // Body for a view_closed event + { + type: 'view_closed', + view: { id: 'V456' }, + }, + ]; +} diff --git a/src/helpers.ts b/src/helpers.ts index f427732f6..a6415d891 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -15,7 +15,7 @@ export enum IncomingEventType { Action, Command, Options, - ViewSubmitAction, + ViewAction, } /** @@ -54,9 +54,9 @@ export function getTypeAndConversation(body: any): { type?: IncomingEventType, c conversationId: actionBody.channel !== undefined ? actionBody.channel.id : undefined, }; } - if (body.type === 'view_submission') { + if (body.type === 'view_submission' || body.type === 'view_closed') { return { - type: IncomingEventType.ViewSubmitAction, + type: IncomingEventType.ViewAction, }; } return {}; diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 75abbc69d..8054191b6 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -5,20 +5,21 @@ import { SlackCommandMiddlewareArgs, SlackEventMiddlewareArgs, SlackOptionsMiddlewareArgs, + SlackViewMiddlewareArgs, SlackEvent, SlackAction, SlashCommand, ViewSubmitAction, + ViewClosedAction, OptionsRequest, InteractiveMessage, DialogSubmitAction, MessageAction, BlockElementAction, ContextMissingPropertyError, - SlackViewMiddlewareArgs, - ViewOutput, + SlackViewAction, } from '../types'; -import { ActionConstraints } from '../App'; +import { ActionConstraints, ViewConstraints } from '../App'; import { ErrorCode, errorWithCode } from '../errors'; /** @@ -74,107 +75,92 @@ export const onlyEvents: Middleware<AnyMiddlewareArgs & { event?: SlackEvent }> }; /** - * Middleware that filters out any event that isn't a view_submission + * Middleware that filters out any event that isn't a view_submission or view_closed event */ -export const onlyViewSubmits: Middleware<AnyMiddlewareArgs & { view?: ViewSubmitAction }> = ({ view, next }) => { - // Filter out anything that isn't a view_submission - if (view === undefined) { - return; - } - - // It matches so we should continue down this middleware listener chain - next(); -}; - -// TODO: is there a way to consolidate the next two methods without too much type refactoring? -export function matchCallbackId( - callbackId: string | RegExp, -): Middleware<SlackViewMiddlewareArgs> { - return ({ payload, next, context }) => { - let tempMatches: RegExpMatchArray | null; - - if (!isCallbackIdentifiedBody(payload)) { +export const onlyViewActions: Middleware<AnyMiddlewareArgs & + { view?: (ViewSubmitAction | ViewClosedAction) }> = ({ view, next }) => { + // Filter out anything that doesn't have a view + if (view === undefined) { return; } - if (typeof callbackId === 'string') { - if (payload.callback_id !== callbackId) { - return; - } - } else { - tempMatches = payload.callback_id.match(callbackId); - - if (tempMatches !== null) { - context['callbackIdMatches'] = tempMatches; - } else { - return; - } - } + // It matches so we should continue down this middleware listener chain next(); }; -} /** * Middleware that checks for matches given constraints */ export function matchConstraints( - constraints: ActionConstraints, -): Middleware<SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs> { + constraints: ActionConstraints | ViewConstraints, + ): Middleware<SlackActionMiddlewareArgs | SlackOptionsMiddlewareArgs | SlackViewMiddlewareArgs> { return ({ payload, body, next, context }) => { // TODO: is putting matches in an array actually helpful? there's no way to know which of the regexps contributed // which matches (and in which order) let tempMatches: RegExpMatchArray | null; - if (constraints.block_id !== undefined) { + // Narrow type for ActionConstraints + if ('block_id' in constraints || 'action_id' in constraints) { if (!isBlockPayload(payload)) { return; } - if (typeof constraints.block_id === 'string') { - if (payload.block_id !== constraints.block_id) { - return; + // Check block_id + if (constraints.block_id !== undefined) { + + if (typeof constraints.block_id === 'string') { + if (payload.block_id !== constraints.block_id) { + return; + } + } else { + tempMatches = payload.block_id.match(constraints.block_id); + + if (tempMatches !== null) { + context['blockIdMatches'] = tempMatches; + } else { + return; + } } - } else { - tempMatches = payload.block_id.match(constraints.block_id); + } - if (tempMatches !== null) { - context['blockIdMatches'] = tempMatches; + // Check action_id + if (constraints.action_id !== undefined) { + if (typeof constraints.action_id === 'string') { + if (payload.action_id !== constraints.action_id) { + return; + } } else { - return; + tempMatches = payload.action_id.match(constraints.action_id); + + if (tempMatches !== null) { + context['actionIdMatches'] = tempMatches; + } else { + return; + } } } } - if (constraints.action_id !== undefined) { - if (!isBlockPayload(payload)) { - return; - } + // Check callback_id + if ('callback_id' in constraints && constraints.callback_id !== undefined) { + let callbackId: string = ''; - if (typeof constraints.action_id === 'string') { - if (payload.action_id !== constraints.action_id) { - return; - } + if (isViewBody(body)) { + callbackId = body['view']['callback_id']; } else { - tempMatches = payload.action_id.match(constraints.action_id); - - if (tempMatches !== null) { - context['actionIdMatches'] = tempMatches; + if (isCallbackIdentifiedBody(body)) { + callbackId = body['callback_id']; } else { return; } } - } - if (constraints.callback_id !== undefined) { - if (!isCallbackIdentifiedBody(body)) { - return; - } if (typeof constraints.callback_id === 'string') { - if (body.callback_id !== constraints.callback_id) { + if (callbackId !== constraints.callback_id) { return; } } else { - tempMatches = body.callback_id.match(constraints.callback_id); + tempMatches = callbackId.match(constraints.callback_id); if (tempMatches !== null) { context['callbackIdMatches'] = tempMatches; @@ -184,6 +170,11 @@ export function matchConstraints( } } + // Check type + if ('type' in constraints) { + if (body.type !== constraints.type) return; + } + next(); }; } @@ -335,7 +326,10 @@ export function directMention(): Middleware<SlackEventMiddlewareArgs<'message'>> } function isBlockPayload( - payload: SlackActionMiddlewareArgs['payload'] | SlackOptionsMiddlewareArgs['payload'], + payload: + | SlackActionMiddlewareArgs['payload'] + | SlackOptionsMiddlewareArgs['payload'] + | SlackViewMiddlewareArgs['payload'], ): payload is BlockElementAction | OptionsRequest<'block_suggestion'> { return (payload as BlockElementAction | OptionsRequest<'block_suggestion'>).action_id !== undefined; } @@ -343,16 +337,24 @@ function isBlockPayload( type CallbackIdentifiedBody = | InteractiveMessage | DialogSubmitAction - | ViewOutput | MessageAction | OptionsRequest<'interactive_message' | 'dialog_suggestion'>; function isCallbackIdentifiedBody( - body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'] | SlackViewMiddlewareArgs['payload'], + body: SlackActionMiddlewareArgs['body'] | SlackOptionsMiddlewareArgs['body'], ): body is CallbackIdentifiedBody { return (body as CallbackIdentifiedBody).callback_id !== undefined; } +function isViewBody( + body: + SlackActionMiddlewareArgs['body'] + | SlackOptionsMiddlewareArgs['body'] + | SlackViewMiddlewareArgs['body'], +): body is SlackViewAction { + return (body as SlackViewAction).view !== undefined; +} + function isEventArgs( args: AnyMiddlewareArgs, ): args is SlackEventMiddlewareArgs { diff --git a/src/types/view/index.ts b/src/types/view/index.ts index 901b3e55a..411df4d51 100644 --- a/src/types/view/index.ts +++ b/src/types/view/index.ts @@ -1,13 +1,18 @@ import { StringIndexed } from '../helpers'; import { RespondArguments, AckFn } from '../utilities'; +/** + * Known view action types + */ +export type SlackViewAction = ViewSubmitAction | ViewClosedAction; +// <ViewAction extends SlackViewAction = ViewSubmitAction> /** * Arguments which listeners and middleware receive to process a view submission event from Slack. */ -export interface SlackViewMiddlewareArgs { +export interface SlackViewMiddlewareArgs<ViewActionType extends SlackViewAction = SlackViewAction> { payload: ViewOutput; view: this['payload']; - body: ViewSubmitAction; + body: ViewActionType; ack: AckFn<string | RespondArguments>; } @@ -40,6 +45,30 @@ export interface ViewSubmitAction { token: string; } +/** + * A Slack view_closed event wrapped in the standard metadata. + * + * This describes the entire JSON-encoded body of a view_closed event. + */ +export interface ViewClosedAction { + type: 'view_closed'; + team: { + id: string; + domain: string; + enterprise_id?: string; // undocumented + enterprise_name?: string; // undocumented + }; + user: { + id: string; + name: string; + team_id?: string; // undocumented + }; + view: ViewOutput; + api_app_id: string; + token: string; + is_cleared: boolean; +} + export interface ViewOutput { id: string; callback_id: string;