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

Fix #904 by improving app.message listener's TS compatibility while bringing breaking changes #1801

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
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
94 changes: 94 additions & 0 deletions src/App-routes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,6 +828,22 @@ describe('App event routing', () => {
ack: noop,
});

const fakeSubtypedMessageEvent = (
receiver: FakeReceiver,
subtype: string,
message: string,
): Promise<void> => 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();
Expand Down Expand Up @@ -1012,6 +1028,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);
Copy link
Contributor

Choose a reason for hiding this comment

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

Just an idea: many of the assert methods take a type parameter that then ensures the type of the argument provided to assert is of the provided type. This way perhaps it is clearer the intention of the test ("verify it compiles").

Suggested change
assert.isNotNull(payload.channel);
assert.isNotNull<string>(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', () => {
Expand Down
117 changes: 100 additions & 17 deletions src/App.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,9 @@ import {
InteractiveAction,
ViewOutput,
KnownOptionsPayloadFromType,
KnownEventFromType,
SlashCommand,
WorkflowStepEdit,
KnownEventFromType,
} from './types';
import { IncomingEventType, getTypeAndConversation, assertNever } from './helpers';
import { CodedError, asCodedError, AppInitializationError, MultipleListenerError, ErrorCode, InvalidCustomPropertyError } from './errors';
Expand Down Expand Up @@ -182,9 +182,17 @@ export interface AnyErrorHandler extends ErrorHandler, ExtendedErrorHandler {
}

// Used only in this file
type MessageEventMiddleware<
type AllMessageEventMiddleware<
CustomContext extends StringIndexed = StringIndexed,
> = Middleware<SlackEventMiddlewareArgs<'message', string | undefined>, CustomContext>;

// Used only in this file
type FilteredMessageEventMiddleware<
CustomContext extends StringIndexed = StringIndexed,
> = Middleware<SlackEventMiddlewareArgs<'message'>, CustomContext>;
> = Middleware<SlackEventMiddlewareArgs<
'message',
undefined | 'bot_message' | 'file_share' | 'thread_broadcast'
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe it is worth extracting this union into its own type?
What does this combination represent? The string literals seem to be message subtypes, but undefined in this context represents what? Generic message event? Message event with no subtype?
Just trying to better understand the intention.

>, CustomContext>;

class WebClientPool {
private pool: { [token: string]: WebClient } = {};
Expand Down Expand Up @@ -535,21 +543,27 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
eventName: EventType,
...listeners: Middleware<SlackEventMiddlewareArgs<EventType>, AppCustomContext & MiddlewareCustomContext>[]
...listeners: Middleware<
SlackEventMiddlewareArgs,
AppCustomContext & MiddlewareCustomContext
>[]
): void;
public event<
EventType extends RegExp = RegExp,
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
eventName: EventType,
...listeners: Middleware<SlackEventMiddlewareArgs<string>, AppCustomContext & MiddlewareCustomContext>[]
...listeners: Middleware<SlackEventMiddlewareArgs, AppCustomContext & MiddlewareCustomContext>[]
): void;
public event<
EventType extends EventTypePattern = EventTypePattern,
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
eventNameOrPattern: EventType,
...listeners: Middleware<SlackEventMiddlewareArgs<string>, AppCustomContext & MiddlewareCustomContext>[]
...listeners: Middleware<
SlackEventMiddlewareArgs,
AppCustomContext & MiddlewareCustomContext
>[]
): void {
let invalidEventName = false;
if (typeof eventNameOrPattern === 'string') {
Expand Down Expand Up @@ -581,7 +595,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
*/
public message<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(...listeners: MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]): void;
>(...listeners: FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]): void;
/**
*
* @param pattern Used for filtering out messages that don't match.
Expand All @@ -592,7 +606,7 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
pattern: string | RegExp,
...listeners: MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
...listeners: FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
): void;
/**
*
Expand All @@ -605,9 +619,9 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
public message<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
filter: MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>,
filter: FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>,
pattern: string | RegExp,
...listeners: MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
...listeners: FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
): void;
/**
*
Expand All @@ -618,8 +632,8 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
public message<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
filter: MessageEventMiddleware,
...listeners: MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
filter: FilteredMessageEventMiddleware,
...listeners: FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
): void;
/**
* This allows for further control of the filtering and response logic. Patterns and middlewares are processed in
Expand All @@ -630,16 +644,78 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>
public message<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>)[]
...patternsOrMiddleware: (
| string
| RegExp
| FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>)[]
): void;
public message<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
...patternsOrMiddleware: (string | RegExp | MessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>)[]
...patternsOrMiddleware: (
| string
| RegExp
| FilteredMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>)[]
): void {
const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => {
if (typeof patternOrMiddleware === 'string' || util.types.isRegExp(patternOrMiddleware)) {
return matchMessage<undefined | 'bot_message' | 'file_share' | 'thread_broadcast'>(patternOrMiddleware, true);
Copy link
Contributor

Choose a reason for hiding this comment

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

If my comment above is followed (to extract this union into its own type), we can then re-use it here.

}
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<AnyMiddlewareArgs>[]);
}

public allMessageSubtypes<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(...listeners: AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]): void;
public allMessageSubtypes<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
pattern: string | RegExp,
...listeners: AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
): void;
public allMessageSubtypes<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
filter: AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>,
pattern: string | RegExp,
Copy link
Contributor

Choose a reason for hiding this comment

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

Just to clarify, filter in these arguments means a middleware filter, whereas pattern is a message text pattern match / filter?

...listeners: AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
): void;
public allMessageSubtypes<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
filter: AllMessageEventMiddleware,
...listeners: AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>[]
): void;
public allMessageSubtypes<
Copy link
Contributor

Choose a reason for hiding this comment

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

Is this overload signature the same as the implementation signature that follows, or am I misreading this?

MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
...patternsOrMiddleware: (
| string
| RegExp
| AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>)[]
): void;
/**
* Accepts all subtype events of message ones.
*/
public allMessageSubtypes<
MiddlewareCustomContext extends StringIndexed = StringIndexed,
>(
...patternsOrMiddleware: (
| string
| RegExp
| AllMessageEventMiddleware<AppCustomContext & MiddlewareCustomContext>)[]
): void {
const messageMiddleware = patternsOrMiddleware.map((patternOrMiddleware) => {
if (typeof patternOrMiddleware === 'string' || util.types.isRegExp(patternOrMiddleware)) {
return matchMessage(patternOrMiddleware);
return matchMessage<string | undefined | never>(patternOrMiddleware, false);
}
return patternOrMiddleware;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Expand Down Expand Up @@ -943,8 +1019,15 @@ export default class App<AppCustomContext extends StringIndexed = StringIndexed>

// Set body and payload
// TODO: this value should eventually conform to AnyMiddlewareArgs
let payload: DialogSubmitAction | WorkflowStepEdit | SlackShortcut | KnownEventFromType<string> | SlashCommand
| KnownOptionsPayloadFromType<string> | BlockElementAction | ViewOutput | InteractiveAction;
let payload: DialogSubmitAction
| WorkflowStepEdit
| SlackShortcut
| KnownEventFromType<string, string | undefined>
| SlashCommand
| KnownOptionsPayloadFromType<string>
| BlockElementAction
| ViewOutput
| InteractiveAction;
switch (type) {
case IncomingEventType.Event:
payload = (bodyArg as SlackEventMiddlewareArgs['body']).event;
Expand Down
13 changes: 8 additions & 5 deletions src/middleware/builtin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
import { onlyCommands, onlyEvents, matchCommandName, matchEventType, subtype } from './builtin';
import { SlashCommand } from '../types/command';
import { AppMentionEvent, AppHomeOpenedEvent } from '../types/events';
import { GenericMessageEvent } from '../types/events/message-events';
import { GenericMessageEvent, MessagePostedEvent } from '../types/events/message-events';

// Test fixtures
const validCommandPayload: SlashCommand = {
Expand Down Expand Up @@ -92,7 +92,7 @@ describe('Built-in global middleware', () => {
function matchesPatternTestCase(
pattern: string | RegExp,
matchingText: string,
buildFakeEvent: (content: string) => SlackEvent,
buildFakeEvent: (content: string) => MessagePostedEvent | AppMentionEvent,
): Mocha.AsyncFunc {
return async () => {
// Arrange
Expand Down Expand Up @@ -859,7 +859,10 @@ interface MiddlewareCommonArgs {
logger: Logger;
client: WebClient;
}
type MessageMiddlewareArgs = SlackEventMiddlewareArgs<'message'> & MiddlewareCommonArgs;
type MessageMiddlewareArgs = SlackEventMiddlewareArgs<
'message' | 'app_mention',
undefined | 'bot_message' | 'file_share' | 'thread_broadcast' | never
> & MiddlewareCommonArgs;
type TokensRevokedMiddlewareArgs = SlackEventMiddlewareArgs<'tokens_revoked'> & MiddlewareCommonArgs;

type MemberJoinedOrLeftChannelMiddlewareArgs = SlackEventMiddlewareArgs<'member_joined_channel' | 'member_left_channel'> & MiddlewareCommonArgs;
Expand All @@ -870,7 +873,7 @@ async function importBuiltin(overrides: Override = {}): Promise<typeof import('.
return rewiremock.module(() => import('./builtin'), overrides);
}

function createFakeMessageEvent(content: string | GenericMessageEvent['blocks'] = ''): MessageEvent {
function createFakeMessageEvent(content: string | GenericMessageEvent['blocks'] = ''): MessagePostedEvent {
const event: Partial<GenericMessageEvent> = {
type: 'message',
channel: 'CHANNEL_ID',
Expand All @@ -882,7 +885,7 @@ function createFakeMessageEvent(content: string | GenericMessageEvent['blocks']
} else {
event.blocks = content;
}
return event as MessageEvent;
return event as MessagePostedEvent;
}

function createFakeAppMentionEvent(text: string = ''): AppMentionEvent {
Expand Down
15 changes: 13 additions & 2 deletions src/middleware/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SlackEventMiddlewareArgs<'message' | 'app_mention'>> {
Copy link
Member Author

Choose a reason for hiding this comment

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

having app_mention here had been false as a preceding built-in middleware matchEventType('message') does not accept app_mention. Moreover, it should not accept two types of events. So, I've removed it.

Copy link
Contributor

Choose a reason for hiding this comment

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

OK so this change implies that matchMessage should only ever be used with a message event - might be useful to clarify that in the JSDoc, as the JSDoc in its current form implies this can be used for any event that contains a message.

onlyMessagePosted: boolean = false, // false for backward compatibility
): Middleware<SlackEventMiddlewareArgs<'message', Subtypes>> {
return async ({ event, context, next }) => {
let tempMatches: RegExpMatchArray | null;

if (!('text' in event) || event.text === undefined) {
return;
}
// Since version 3.14, handling only message posted events are allowed
if (onlyMessagePosted &&
event.type === 'message' &&
!messagePostedEventSubtypesAsArray.includes(event.subtype)) {
return;
}

// Filter out messages or app mentions that don't contain the pattern
if (typeof pattern === 'string') {
Expand Down
Loading