diff --git a/src/App-routes.spec.ts b/src/App-routes.spec.ts index e1e22f04b..dfd50abb2 100644 --- a/src/App-routes.spec.ts +++ b/src/App-routes.spec.ts @@ -840,6 +840,22 @@ describe('App event routing', () => { ack: noop, }); + const fakeSubtypedMessageEvent = ( + receiver: FakeReceiver, + subtype: string, + message: string, + ): Promise => receiver.sendEvent({ + body: { + type: 'event_callback', + event: { + type: 'message', + subtype, + text: message, + }, + }, + ack: noop, + }); + const controlledMiddleware = (shouldCallNext: boolean) => async ({ next }: { next?: NextFn }) => { if (next && shouldCallNext) { await next(); @@ -1024,6 +1040,84 @@ describe('App event routing', () => { // Assert assertMiddlewaresNotCalled(); }); + + it('should handle bot_message events', async () => { + // Act + app.message(PASS_STRING, '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'bot_message', message); + // Assert + assertMiddlewaresCalledOnce(); + assertMiddlewaresCalledOrder(); + }); + it('should not handle bot_message events when the constraints do not match', async () => { + // Act + app.message('foo-bar', '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'bot_message', message); + // Assert + assert.isFalse(fakeMiddleware1.calledOnce); + }); + it('should handle file_share events', async () => { + // Act + app.message(PASS_STRING, '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'file_share', message); + // Assert + assertMiddlewaresCalledOnce(); + assertMiddlewaresCalledOrder(); + }); + it('should not handle file_share events when the constraints do not match', async () => { + // Act + app.message('foo-bar', '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'file_share', message); + // Assert + assert.isFalse(fakeMiddleware1.calledOnce); + }); + it('should handle thread_broadcast events', async () => { + // Act + app.message(PASS_STRING, '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'thread_broadcast', message); + // Assert + assertMiddlewaresCalledOnce(); + assertMiddlewaresCalledOrder(); + }); + it('should not handle message_changed events', async () => { + // Act + app.message(PASS_STRING, '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'message_changed', message); + // Assert + assert.isFalse(fakeMiddleware1.calledOnce); + }); + it('should handle message_changed events when using allMessageSubtypes', async () => { + // Act + app.allMessageSubtypes(PASS_STRING, '- val', ...fakeMiddlewares); + await fakeSubtypedMessageEvent(fakeReceiver, 'message_changed', message); + // Assert + assertMiddlewaresCalledOnce(); + assertMiddlewaresCalledOrder(); + }); + + it('should provide better typed payloads', async () => { + app.message(async ({ payload }) => { + // verify it compiles + assert.isNotNull(payload.channel); + assert.isNotNull(payload.ts); + assert.isNotNull(payload.text); + assert.isNotNull(payload.blocks); + assert.isNotNull(payload.attachments); + }); + app.allMessageSubtypes(async ({ payload }) => { + // verify it compiles + if ((!payload.subtype || + payload.subtype === 'bot_message' || + payload.subtype === 'file_share' || + payload.subtype === 'thread_broadcast')) { + assert.isNotNull(payload.channel); + assert.isNotNull(payload.ts); + assert.isNotNull(payload.text); + assert.isNotNull(payload.blocks); + assert.isNotNull(payload.attachments); + } + }); + }); }); describe('Quick type compatibility checks', () => { diff --git a/src/App.ts b/src/App.ts index 5fea2290c..5213dcc96 100644 --- a/src/App.ts +++ b/src/App.ts @@ -50,9 +50,9 @@ import { InteractiveAction, ViewOutput, KnownOptionsPayloadFromType, - KnownEventFromType, SlashCommand, WorkflowStepEdit, + KnownEventFromType, SlackOptions, } from './types'; import { IncomingEventType, getTypeAndConversation, assertNever, isBodyWithTypeEnterpriseInstall, isEventTypeToSkipAuthorize } from './helpers'; @@ -193,9 +193,17 @@ export interface AnyErrorHandler extends ErrorHandler, ExtendedErrorHandler { } // Used only in this file -type MessageEventMiddleware< +type AllMessageEventMiddleware< + CustomContext extends StringIndexed = StringIndexed, +> = Middleware, CustomContext>; + +// Used only in this file +type FilteredMessageEventMiddleware< CustomContext extends StringIndexed = StringIndexed, -> = Middleware, CustomContext>; +> = Middleware, CustomContext>; class WebClientPool { private pool: { [token: string]: WebClient } = {}; @@ -546,21 +554,27 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( eventName: EventType, - ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackEventMiddlewareArgs, + AppCustomContext & MiddlewareCustomContext + >[] ): void; public event< EventType extends RegExp = RegExp, MiddlewareCustomContext extends StringIndexed = StringIndexed, >( eventName: EventType, - ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware[] ): void; public event< EventType extends EventTypePattern = EventTypePattern, MiddlewareCustomContext extends StringIndexed = StringIndexed, >( eventNameOrPattern: EventType, - ...listeners: Middleware, AppCustomContext & MiddlewareCustomContext>[] + ...listeners: Middleware< + SlackEventMiddlewareArgs, + AppCustomContext & MiddlewareCustomContext + >[] ): void { let invalidEventName = false; if (typeof eventNameOrPattern === 'string') { @@ -592,7 +606,7 @@ export default class App */ public message< MiddlewareCustomContext extends StringIndexed = StringIndexed, - >(...listeners: MessageEventMiddleware[]): void; + >(...listeners: FilteredMessageEventMiddleware[]): void; /** * * @param pattern Used for filtering out messages that don't match. @@ -603,7 +617,7 @@ export default class App MiddlewareCustomContext extends StringIndexed = StringIndexed, >( pattern: string | RegExp, - ...listeners: MessageEventMiddleware[] + ...listeners: FilteredMessageEventMiddleware[] ): void; /** * @@ -616,9 +630,9 @@ export default class App public message< MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - filter: MessageEventMiddleware, + filter: FilteredMessageEventMiddleware, pattern: string | RegExp, - ...listeners: MessageEventMiddleware[] + ...listeners: FilteredMessageEventMiddleware[] ): void; /** * @@ -629,8 +643,8 @@ export default class App public message< MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - filter: MessageEventMiddleware, - ...listeners: MessageEventMiddleware[] + filter: FilteredMessageEventMiddleware, + ...listeners: FilteredMessageEventMiddleware[] ): void; /** * This allows for further control of the filtering and response logic. Patterns and middlewares are processed in @@ -641,16 +655,78 @@ export default class App public message< MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - ...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware)[] + ...patternsOrMiddleware: ( + | string + | RegExp + | FilteredMessageEventMiddleware)[] ): void; public message< MiddlewareCustomContext extends StringIndexed = StringIndexed, >( - ...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware)[] + ...patternsOrMiddleware: ( + | string + | RegExp + | FilteredMessageEventMiddleware)[] + ): void { + const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => { + if (typeof patternOrMiddleware === 'string' || util.types.isRegExp(patternOrMiddleware)) { + return matchMessage(patternOrMiddleware, true); + } + return patternOrMiddleware; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any; // FIXME: workaround for TypeScript 4.7 breaking changes + + this.listeners.push([ + onlyEvents, + matchEventType('message'), + ...messageMiddleware, + ] as Middleware[]); + } + + public allMessageSubtypes< + MiddlewareCustomContext extends StringIndexed = StringIndexed, + >(...listeners: AllMessageEventMiddleware[]): void; + public allMessageSubtypes< + MiddlewareCustomContext extends StringIndexed = StringIndexed, + >( + pattern: string | RegExp, + ...listeners: AllMessageEventMiddleware[] + ): void; + public allMessageSubtypes< + MiddlewareCustomContext extends StringIndexed = StringIndexed, + >( + filter: AllMessageEventMiddleware, + pattern: string | RegExp, + ...listeners: AllMessageEventMiddleware[] + ): void; + public allMessageSubtypes< + MiddlewareCustomContext extends StringIndexed = StringIndexed, + >( + filter: AllMessageEventMiddleware, + ...listeners: AllMessageEventMiddleware[] + ): void; + public allMessageSubtypes< + MiddlewareCustomContext extends StringIndexed = StringIndexed, + >( + ...patternsOrMiddleware: ( + | string + | RegExp + | AllMessageEventMiddleware)[] + ): void; + /** + * Accepts all subtype events of message ones. + */ + public allMessageSubtypes< + MiddlewareCustomContext extends StringIndexed = StringIndexed, + >( + ...patternsOrMiddleware: ( + | string + | RegExp + | AllMessageEventMiddleware)[] ): void { const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => { if (typeof patternOrMiddleware === 'string' || util.types.isRegExp(patternOrMiddleware)) { - return matchMessage(patternOrMiddleware); + return matchMessage(patternOrMiddleware, false); } return patternOrMiddleware; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -960,8 +1036,15 @@ export default class App // Set body and payload // TODO: this value should eventually conform to AnyMiddlewareArgs - let payload: DialogSubmitAction | WorkflowStepEdit | SlackShortcut | KnownEventFromType | SlashCommand - | KnownOptionsPayloadFromType | BlockElementAction | ViewOutput | InteractiveAction; + let payload: DialogSubmitAction + | WorkflowStepEdit + | SlackShortcut + | KnownEventFromType + | SlashCommand + | KnownOptionsPayloadFromType + | BlockElementAction + | ViewOutput + | InteractiveAction; switch (type) { case IncomingEventType.Event: payload = (bodyArg as SlackEventMiddlewareArgs['body']).event; diff --git a/src/middleware/builtin.spec.ts b/src/middleware/builtin.spec.ts index d1f76b763..cd4649512 100644 --- a/src/middleware/builtin.spec.ts +++ b/src/middleware/builtin.spec.ts @@ -92,7 +92,7 @@ describe('Built-in global middleware', () => { function matchesPatternTestCase( pattern: string | RegExp, matchingText: string, - buildFakeEvent: (content: string) => SlackEvent, + buildFakeEvent: (content: string) => AppMentionEvent | MessageEvent, ): Mocha.AsyncFunc { return async () => { // Arrange @@ -859,7 +859,10 @@ interface MiddlewareCommonArgs { logger: Logger; client: WebClient; } -type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & MiddlewareCommonArgs; +type MessageMiddlewareArgs = SlackEventMiddlewareArgs< +'message', +undefined | 'bot_message' | 'file_share' | 'thread_broadcast' +> & MiddlewareCommonArgs; type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> & MiddlewareCommonArgs; type MemberJoinedOrLeftChannelMiddlewareArgs = SlackEventMiddlewareArgs<'member_joined_channel' | 'member_left_channel'> & MiddlewareCommonArgs; diff --git a/src/middleware/builtin.ts b/src/middleware/builtin.ts index 64dfe9714..11f629788 100644 --- a/src/middleware/builtin.ts +++ b/src/middleware/builtin.ts @@ -206,18 +206,29 @@ export function matchConstraints( }; } +const messagePostedEventSubtypesAsArray = [undefined, 'bot_message', 'file_share', 'thread_broadcast']; + /* * Middleware that filters out messages that don't match pattern */ -export function matchMessage( +export function matchMessage< + Subtypes extends string | undefined = string | undefined, +>( pattern: string | RegExp, -): Middleware> { + onlyMessagePosted: boolean = false, // false for backward compatibility +): Middleware> { return async ({ event, context, next }) => { let tempMatches: RegExpMatchArray | null; if (!('text' in event) || event.text === undefined) { return; } + if (onlyMessagePosted && + event.type === 'message' && + !messagePostedEventSubtypesAsArray.includes(event.subtype)) { + // Handle only message posted events + return; + } // Filter out messages or app mentions that don't contain the pattern if (typeof pattern === 'string') { diff --git a/src/types/events/base-events.ts b/src/types/events/base-events.ts index b5ad2a2bd..c6ed57dc5 100644 --- a/src/types/events/base-events.ts +++ b/src/types/events/base-events.ts @@ -95,8 +95,12 @@ export type EventTypePattern = string | RegExp; * this interface. That condition isn't enforced, since we're not interested in factoring out common properties from the * known event types. */ -export interface BasicSlackEvent { +export interface BasicSlackEvent< + Type extends string = string, + Subtype extends string | undefined = string | undefined, +> { type: Type; + subtype: Type extends 'message' | 'emoji_changed' ? Subtype : never; } interface BotProfile { diff --git a/src/types/events/index.ts b/src/types/events/index.ts index b888b4a00..4a6f6c1a4 100644 --- a/src/types/events/index.ts +++ b/src/types/events/index.ts @@ -4,6 +4,7 @@ import { SayFn } from '../utilities'; export * from './base-events'; export { + MessagePostedEvent, GenericMessageEvent, BotMessageEvent, ChannelArchiveMessageEvent, @@ -26,8 +27,11 @@ export { /** * Arguments which listeners and middleware receive to process an event from Slack's Events API. */ -export interface SlackEventMiddlewareArgs { - payload: EventFromType; +export interface SlackEventMiddlewareArgs< + EventType extends string = string, + EventSubtype extends string | undefined | never = string | undefined | never, +> { + payload: EventFromType; event: this['payload']; message: EventType extends 'message' ? this['payload'] : never; body: EnvelopedEvent; @@ -71,10 +75,19 @@ interface Authorization { * When the string matches known event(s) from the `SlackEvent` union, only those types are returned (also as a union). * Otherwise, the `BasicSlackEvent` type is returned. */ -export type EventFromType = KnownEventFromType extends never ? +export type EventFromType< + T extends string, + ST extends string | undefined | never, +> = KnownEventFromType extends never ? BasicSlackEvent : - KnownEventFromType; -export type KnownEventFromType = Extract; + KnownEventFromType; + +export type KnownEventFromType< + T extends string, + ST extends string | undefined | never = string | undefined | never, +> = T extends 'message' | 'emoji_changed' + ? Extract + : Extract; /** * Type function which tests whether or not the given `Event` contains a channel ID context for where the event diff --git a/src/types/events/message-events.ts b/src/types/events/message-events.ts index c64fe144a..e565d6260 100644 --- a/src/types/events/message-events.ts +++ b/src/types/events/message-events.ts @@ -1,5 +1,11 @@ import { MessageAttachment, KnownBlock, Block, MessageMetadata } from '@slack/types'; +export type MessagePostedEvent = + | GenericMessageEvent + | BotMessageEvent + | FileShareMessageEvent + | ThreadBroadcastMessageEvent; + export type MessageEvent = | GenericMessageEvent | BotMessageEvent diff --git a/types-tests/event.test-d.ts b/types-tests/event.test-d.ts index b24a6687a..9e73a5d21 100644 --- a/types-tests/event.test-d.ts +++ b/types-tests/event.test-d.ts @@ -1,5 +1,5 @@ import { expectNotType, expectType } from 'tsd'; -import { App, SlackEvent, AppMentionEvent, ReactionAddedEvent, ReactionRemovedEvent, UserHuddleChangedEvent, UserProfileChangedEvent, UserStatusChangedEvent, PinAddedEvent, SayFn, PinRemovedEvent } from '..'; +import { App, SlackEvent, AppMentionEvent, ReactionAddedEvent, ReactionRemovedEvent, UserHuddleChangedEvent, UserProfileChangedEvent, UserStatusChangedEvent, PinAddedEvent, SayFn, PinRemovedEvent, EmojiChangedEvent } from '..'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); @@ -80,3 +80,11 @@ expectType( await Promise.resolve(event); }) ); + +expectType( + app.event('emoji_changed', async ({ event }) => { + expectType(event); + expectNotType(event); + await Promise.resolve(event); + }) +); diff --git a/types-tests/message.test-d.ts b/types-tests/message.test-d.ts index 65b24e9c1..c2517f181 100644 --- a/types-tests/message.test-d.ts +++ b/types-tests/message.test-d.ts @@ -1,5 +1,5 @@ import { expectNotType, expectType, expectError } from 'tsd'; -import { App, MessageEvent, GenericMessageEvent, BotMessageEvent, MessageRepliedEvent, MeMessageEvent, MessageDeletedEvent, ThreadBroadcastMessageEvent, MessageChangedEvent, EKMAccessDeniedMessageEvent } from '..'; +import { App, MessageEvent, GenericMessageEvent, BotMessageEvent, MessageRepliedEvent, MeMessageEvent, MessageDeletedEvent, ThreadBroadcastMessageEvent, MessageChangedEvent, EKMAccessDeniedMessageEvent, MessagePostedEvent } from '..'; const app = new App({ token: 'TOKEN', signingSecret: 'Signing Secret' }); @@ -7,6 +7,40 @@ expectType( // TODO: Resolve the event type when having subtype in a listener constraint // app.message({pattern: 'foo', subtype: 'message_replied'}, async ({ message }) => {}); app.message(async ({ message }) => { + expectType(message); + + message.channel; // the property access should compile + message.user; // the property access should compile + + if (message.subtype === undefined) { + expectType(message); + expectNotType(message); + message.user; // the property access should compile + message.channel; // the property access should compile + message.team; // the property access should compile + } + if (message.subtype === 'bot_message') { + expectType(message); + expectNotType(message); + message.user; // the property access should compile + message.channel; // the property access should compile + } + if (message.subtype === 'thread_broadcast') { + expectType(message); + expectNotType(message); + message.channel; // the property access should compile + message.thread_ts; // the property access should compile + message.ts; // the property access should compile + message.root; // the property access should compile + } + + await Promise.resolve(message); + }) +); + + +expectType( + app.allMessageSubtypes(async ({ message }) => { expectType(message); message.channel; // the property access should compile @@ -68,5 +102,5 @@ expectType( } await Promise.resolve(message); - }), -); + }) +); \ No newline at end of file