Skip to content

Collectors

Sometimes your bot needs to wait for something. Maybe you want to ask a moderator to confirm a ban before it goes through. Maybe you need them to type a reason in chat. Maybe you want to collect a thumbs-up vote from the team before taking action.

Discord.js has collectors for all of these, but wiring them up correctly every time — managing timeouts, filtering by user, handling edge cases — gets repetitive fast. Warden’s Collector class wraps all of this into a consistent, chainable API that reads like plain English.

Every collector follows the same pattern: create it, configure it with chained methods, and call .collect() to await the result. The return value is always the collected interaction, message, or reaction — or null if the collector timed out.

The most common use case: you send a message with buttons and want to know which one the user clicks.

import {Collector} from '@warden/core';
const result = await new Collector(interaction)
.buttons('confirm', 'cancel')
.timeout(30_000)
.from(interaction.user)
.collect();
if (result?.customId === 'confirm') {
// They confirmed
} else {
// They cancelled or the collector timed out
}

The .buttons() method accepts one or more button custom IDs to listen for. The collector will only resolve when one of those specific buttons is clicked — any other buttons are ignored.

MethodDescription
.buttons(...ids)Listen for clicks on buttons with these custom IDs
.timeout(ms)How long to wait before giving up (in milliseconds)
.from(user)Only accept interactions from this specific user
.collect()Start collecting and return a promise with the result (or null)

Sometimes you need the user to type something — a ban reason, a configuration value, or a free-form response. The message collector waits for a message in the same channel:

import {Collector} from '@warden/core';
const message = await new Collector(interaction)
.message()
.timeout(60_000)
.from(interaction.user)
.collect();
if (message) {
const reason = message.content;
// Use the reason...
} else {
// They didn't respond in time
}

The .message() method switches the collector to message mode. It will wait for the next message in the channel from the specified user.

You may also want to filter messages by content. The .filter() method accepts a predicate function:

const message = await new Collector(interaction)
.message()
.timeout(60_000)
.from(interaction.user)
.filter((msg) => msg.content.length > 0 && msg.content.length <= 500)
.collect();

Messages that don’t match the filter are silently ignored, and the collector keeps waiting until a matching message arrives or the timeout expires.

For simple voting or approval flows, reaction collectors let you wait for specific emoji reactions on a message:

import {Collector} from '@warden/core';
const reaction = await new Collector(interaction)
.reaction('\ud83d\udc4d', '\ud83d\udc4e')
.timeout(30_000)
.collect();
if (reaction?.emoji.name === '\ud83d\udc4d') {
// Approved
} else if (reaction?.emoji.name === '\ud83d\udc4e') {
// Rejected
} else {
// Timed out
}

The .reaction() method accepts one or more emoji to listen for. Only reactions with those specific emoji will trigger the collector.

The .filter() method is available on all collector types. It accepts a function that receives the collected item and returns a boolean:

// Only accept messages that start with a number
.filter((msg) => /^\d+/.test(msg.content))
// Only accept button clicks from users with a specific role
.filter((interaction) => interaction.member.roles.cache.has(modRoleId))
// Only accept reactions from non-bot users
.filter((reaction, user) => !user.bot)

If the filter returns false, that particular event is ignored and the collector continues waiting. The timeout clock keeps ticking, so the user still has to respond with something that passes the filter before time runs out.

Every collector should have a timeout. Discord interactions have their own expiry windows, and leaving a collector running indefinitely is a recipe for memory leaks and confused users.

The .timeout() method accepts a duration in milliseconds:

.timeout(15_000) // 15 seconds -- good for quick confirmations
.timeout(30_000) // 30 seconds -- standard for button clicks
.timeout(60_000) // 1 minute -- reasonable for typed responses
.timeout(300_000) // 5 minutes -- for complex input flows

When a collector times out, .collect() resolves with null. Always handle this case:

const result = await new Collector(interaction)
.buttons('confirm', 'cancel')
.timeout(30_000)
.from(interaction.user)
.collect();
if (!result) {
reply(interaction, Embed.warning('You took too long to respond. Action cancelled.'));
return;
}

Here’s a complete ban command that asks for confirmation before proceeding:

import {command, Command, permissions, CommandInteraction, Params, Collector, Embed, reply, Btn, Row} from '@warden/core';
@command({ name: 'ban', description: 'Ban a member from the server' })
@permissions({ user: ['BanMembers'], bot: ['BanMembers'] })
export default class BanCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
const reason = interaction.options.getString('reason') ?? 'No reason provided';
// Send confirmation prompt with buttons
await reply(interaction, {
embeds: [
Embed.warning(
`Are you sure you want to ban **${target.tag}**?\nReason: ${reason}`
),
],
components: [
Row(
Btn.danger('Ban', 'confirm-ban'),
Btn.secondary('Cancel', 'cancel-ban'),
),
],
});
// Wait for the moderator to click a button
const result = await new Collector(interaction)
.buttons('confirm-ban', 'cancel-ban')
.timeout(30_000)
.from(interaction.user)
.collect();
if (result?.customId === 'confirm-ban') {
await interaction.guild!.members.ban(target, { reason });
await reply(interaction, Embed.success(`**${target.tag}** has been banned. Reason: ${reason}`));
} else {
await reply(interaction, Embed.info('Ban cancelled.'));
}
}
}

The moderator sees a warning embed with “Ban” and “Cancel” buttons. They have 30 seconds to decide. If they confirm, the ban goes through. If they cancel or don’t respond in time, nothing happens.

Sometimes you want to collect a reason after the initial command, rather than requiring it as a command option. This gives the moderator more space to explain:

import {command, Command, permissions, CommandInteraction, Params, Collector, Embed, reply} from '@warden/core';
@command({ name: 'warn', description: 'Warn a member with a detailed reason' })
@permissions({ user: ['ModerateMembers'] })
export default class WarnCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const target = interaction.options.getUser('user')!;
// Ask the moderator to type a reason
await reply(interaction, Embed.info(
`Please type the warning reason for **${target.tag}** in chat.\nYou have 60 seconds.`
));
// Wait for their message
const message = await new Collector(interaction)
.message()
.timeout(60_000)
.from(interaction.user)
.filter((msg) => msg.content.length >= 5)
.collect();
if (!message) {
await reply(interaction, Embed.warning('No reason provided. Warning cancelled.'));
return;
}
const reason = message.content;
// Clean up the reason message
await message.delete().catch(() => {});
// Issue the warning
// ... save to database ...
await reply(interaction, Embed.success(
`**${target.tag}** has been warned.\nReason: ${reason}`
));
}
}

The filter ensures the moderator types at least 5 characters — single-word reasons like “bad” won’t cut it. If they don’t respond in time, the whole flow is cleanly cancelled.

For informal moderator voting, reactions provide a lightweight alternative to buttons:

import {command, Command, permissions, CommandInteraction, Params, Collector, Embed, reply} from '@warden/core';
@command({ name: 'appeal-review', description: 'Review a ban appeal' })
@permissions({ user: ['BanMembers'] })
export default class AppealReviewCommand implements Command {
public async execute(interaction: CommandInteraction, params: Params): Promise<void> {
const appealId = interaction.options.getString('appeal-id')!;
// Fetch the appeal from your database
const appeal = database.getAppeal(appealId);
// Show the appeal details
const msg = await reply(interaction, {
embeds: [
Embed.info(
`**Ban Appeal #${appeal.id}**\n\n` +
`User: ${appeal.user.tag}\n` +
`Reason for ban: ${appeal.banReason}\n` +
`Appeal message: ${appeal.message}\n\n` +
`React with \ud83d\udc4d to approve or \ud83d\udc4e to deny.`
),
],
});
const reaction = await new Collector(interaction)
.reaction('\ud83d\udc4d', '\ud83d\udc4e')
.timeout(300_000)
.from(interaction.user)
.collect();
if (reaction?.emoji.name === '\ud83d\udc4d') {
// Unban the user...
await reply(interaction, Embed.success(`Appeal #${appeal.id} approved. User has been unbanned.`));
} else if (reaction?.emoji.name === '\ud83d\udc4e') {
await reply(interaction, Embed.error(`Appeal #${appeal.id} denied.`));
} else {
await reply(interaction, Embed.warning('Review timed out. No action taken.'));
}
}
}

This gives the moderator a generous 5-minute window to read the appeal and react. Simple, focused, and no extra UI to build.