Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
73 changes: 73 additions & 0 deletions src/middleware/builtin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,79 @@ export const ignoreSelf: Middleware<AnyMiddlewareArgs> = async (args) => {
await args.next();
};

/**
* Well-known user ID for Slackbot. Messages from this user are not flagged with bot_id,
* so they require a separate check.
*/
const SLACKBOT_USER_ID = 'USLACKBOT';

/**
* Options for configuring the agent event filter.
*/
export interface AgentEventFilterOptions {
/**
* Subtypes to allow through the filter. By default, all subtypes are blocked.
* For example, `{ allow: ['file_share'] }` lets file upload messages pass through
* alongside plain user messages.
*/
allow?: string[];
/**
* Additional subtypes to block. Use this to block events that would otherwise
* pass the default filter.
*/
block?: string[];
}

/**
* Listener middleware that filters out events agents typically don't need.
* Works with any event listener: `app.message()`, `app.event('app_mention')`, etc.
*
* By default (no arguments), only plain human user messages pass through.
* Everything else is blocked:
* - All message subtypes (message_changed, message_deleted, channel_join, etc.)
* - Bot messages (any event with bot_id, including subtype 'bot_message')
* - Slackbot messages
*
* Use `allow` to let specific subtypes through, and `block` to add extra restrictions.
*
* @example
* // Default: only plain user messages
* app.message(agentEventFilter(), async ({ message }) => { ... });
* app.event('app_mention', agentEventFilter(), async ({ event }) => { ... });
*
* // Also allow file_share messages from users
* app.message(agentEventFilter({ allow: ['file_share'] }), async ({ message }) => { ... });
*/
export function agentEventFilter(options?: AgentEventFilterOptions): Middleware<SlackEventMiddlewareArgs> {
const allowedSubtypes = new Set(options?.allow ?? []);
const blockedSubtypes = new Set(options?.block ?? []);

return async (args) => {
const event = args.event;
if (!event) return;

// Block bots and slackbot
if (isEventFromBot(event)) return;

const eventSubtype = 'subtype' in event ? event.subtype : undefined;

// Has a subtype — blocked by default, unless explicitly allowed
if (eventSubtype !== undefined) {
if (allowedSubtypes.has(eventSubtype) && !blockedSubtypes.has(eventSubtype)) {
await args.next();
}
return;
}

// No subtype, not a bot — plain user message, let it through
await args.next();
};
}

function isEventFromBot(event: SlackEventMiddlewareArgs['event']): boolean {
return ('bot_id' in event && event.bot_id !== undefined) || ('user' in event && event.user === SLACKBOT_USER_ID);
}

// TODO: breaking change: constrain the subtype argument to be a valid message subtype
/**
* Filters out any message events whose subtype does not match the provided subtype.
Expand Down
266 changes: 266 additions & 0 deletions test/unit/middleware/builtin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,272 @@ describe('Built-in global middleware', () => {
});
});

describe('agentEventFilter', () => {
describe('default (no options)', () => {
it('should continue for plain user messages', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs(), ctx);
await builtins.agentEventFilter()(args);
sinon.assert.calledOnce(args.next);
});

it('should continue for plain app_mention events from users', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(createDummyAppMentionEventMiddlewareArgs(), ctx);
await builtins.agentEventFilter()(args);
sinon.assert.calledOnce(args.next);
});

it('should filter out any event with a subtype', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: 'message_changed',
channel,
channel_type: 'channel',
event_ts: ts,
ts,
hidden: true,
message: {
type: 'message',
text: 'edited text',
user: 'U1234',
ts,
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
previous_message: {
type: 'message',
text: 'original text',
user: 'U1234',
ts,
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
await builtins.agentEventFilter()(args);
sinon.assert.notCalled(args.next);
});

it('should filter out bot_message subtypes', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: 'bot_message',
bot_id: 'B1234',
channel,
channel_type: 'channel',
event_ts: ts,
text: 'bot says hi',
ts,
},
}),
ctx,
);
await builtins.agentEventFilter()(args);
sinon.assert.notCalled(args.next);
});

it('should filter out messages with bot_id even without subtype', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: undefined,
event_ts: ts,
channel,
channel_type: 'channel',
user: 'U1234',
ts,
text: 'hi',
blocks: [],
bot_id: 'B5678',
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
await builtins.agentEventFilter()(args);
sinon.assert.notCalled(args.next);
});

it('should filter out slackbot messages', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs({ user: 'USLACKBOT' }), ctx);
await builtins.agentEventFilter()(args);
sinon.assert.notCalled(args.next);
});

it('should filter out app_mention events from bots', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyAppMentionEventMiddlewareArgs({
event: {
type: 'app_mention',
text: '<@U1234> hi',
user: 'U5678',
bot_id: 'B1234',
channel,
ts,
event_ts: ts,
},
}),
ctx,
);
await builtins.agentEventFilter()(args);
sinon.assert.notCalled(args.next);
});

it('should allow edited messages that have no subtype', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: undefined,
event_ts: ts,
channel,
channel_type: 'channel',
user: 'U1234',
ts,
text: 'edited text',
blocks: [],
edited: { user: 'U1234', ts },
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
await builtins.agentEventFilter()(args);
sinon.assert.calledOnce(args.next);
});
});

describe('allow option', () => {
it('should let allowed subtypes through', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: 'file_share',
text: 'uploaded a file',
user: 'U1234',
channel,
channel_type: 'channel',
ts,
event_ts: ts,
files: [],
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
await builtins.agentEventFilter({ allow: ['file_share'] })(args);
sinon.assert.calledOnce(args.next);
});

it('should still block subtypes not in allow list', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: 'message_changed',
channel,
channel_type: 'channel',
event_ts: ts,
ts,
hidden: true,
message: {
type: 'message',
text: 'edited',
user: 'U1234',
ts,
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
previous_message: {
type: 'message',
text: 'original',
user: 'U1234',
ts,
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
await builtins.agentEventFilter({ allow: ['file_share'] })(args);
sinon.assert.notCalled(args.next);
});

it('should still block allowed subtypes from bots', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: 'file_share',
text: 'uploaded a file',
user: 'U1234',
bot_id: 'B1234',
channel,
channel_type: 'channel',
ts,
event_ts: ts,
files: [],
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
await builtins.agentEventFilter({ allow: ['file_share'] })(args);
sinon.assert.notCalled(args.next);
});

it('should still allow plain user messages alongside allowed subtypes', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(createDummyMessageEventMiddlewareArgs(), ctx);
await builtins.agentEventFilter({ allow: ['file_share'] })(args);
sinon.assert.calledOnce(args.next);
});
});

describe('block option', () => {
it('should block additional subtypes', async () => {
const ctx = { ...dummyContext };
const args = wrapMiddleware(
createDummyMessageEventMiddlewareArgs({
message: {
type: 'message',
subtype: 'file_share',
text: 'uploaded a file',
user: 'U1234',
channel,
channel_type: 'channel',
ts,
event_ts: ts,
files: [],
// biome-ignore lint/suspicious/noExplicitAny: mock event structure
} as any,
}),
ctx,
);
// file_share is allowed, but block overrides it
await builtins.agentEventFilter({ allow: ['file_share'], block: ['file_share'] })(args);
sinon.assert.notCalled(args.next);
});
});
});

describe('subtype', () => {
it('should continue middleware processing for match message subtypes', async () => {
const ctx = { ...dummyContext };
Expand Down
Loading