Een Discord giveaway-bot is een van de handigste tools om evenementen op je server te automatiseren: je start een giveaway met één commando, leden doen mee met één klik, en als de timer afloopt kiest de bot eerlijk de winnaar en kondigt die aan. In dit artikel bouwen we vanaf nul een getimed giveaway-systeem met discord.js v14: deelnemers verzamelen via een knop, een aftelling, willekeurige winnaarselectie en persistentie zodat giveaways een herstart van de bot overleven. In plaats van de oude reactie- (emoji-)methode gebruiken we knoppen, omdat ze schoner, betrouwbaarder en moeilijker te manipuleren zijn.
Architectuur van een giveaway-systeem
Een giveaway kent in de kern drie toestanden: starten, deelnames verzamelen en afsluiten. Voor elke actieve giveaway moet je het volgende opslaan:
- Het kanaal en de message-ID waar de giveaway staat (
messageId). - De prijstekst en het aantal winnaars (
winnerCount). - De eindtijd (
endsAt, altijd een absolute tijdstempel). - De gebruikers-ID's van de deelnemers.
Als je deze status alleen in het geheugen bewaart (een Map), wist elke herstart alle actieve giveaways. Daarom is een persistente opslag (zoals SQLite) essentieel — maar laten we eerst de logica vastleggen en daarna persistentie toevoegen.
Het slash-commando dat een giveaway start
We starten de giveaway met een slash-commando. De duur accepteren in een leesbare vorm zoals 10m, 2h of 1d verbetert de gebruikerservaring aanzienlijk. Eerst een klein hulpje dat die tekst omzet naar milliseconden:
function parseDuration(input) {
const match = input.match(/^(\d+)\s*(s|m|h|d)$/i);
if (!match) return null;
const value = parseInt(match[1], 10);
const unit = match[2].toLowerCase();
const factor = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
return value * factor[unit];
}
Het commando zelf neemt de prijs, de duur en het aantal winnaars, en stuurt vervolgens een embed samen met een "Meedoen"-knop:
const {
SlashCommandBuilder,
EmbedBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} = require('discord.js');
const data = new SlashCommandBuilder()
.setName('giveaway')
.setDescription('Start een nieuwe giveaway')
.addStringOption(o =>
o.setName('prijs').setDescription('Prijs').setRequired(true))
.addStringOption(o =>
o.setName('duur').setDescription('bijv. 10m, 2h, 1d').setRequired(true))
.addIntegerOption(o =>
o.setName('winnaars').setDescription('Aantal winnaars').setMinValue(1));
async function execute(interaction) {
const prize = interaction.options.getString('prijs');
const ms = parseDuration(interaction.options.getString('duur'));
const winners = interaction.options.getInteger('winnaars') ?? 1;
if (!ms) {
return interaction.reply({ content: 'Ongeldig duurformaat.', ephemeral: true });
}
const endsAt = Date.now() + ms;
const embed = new EmbedBuilder()
.setTitle('🎉 Giveaway')
.setDescription(`**Prijs:** ${prize}\n**Winnaars:** ${winners}\n**Eindigt:** <t:${Math.floor(endsAt / 1000)}:R>`)
.setColor(0x5865f2);
const join = new ButtonBuilder()
.setCustomId('giveaway_join')
.setLabel('Meedoen')
.setEmoji('🎉')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder().addComponents(join);
const message = await interaction.reply({
embeds: [embed], components: [row], fetchReply: true,
});
// Sla de giveaway op (zie de persistentiesectie hieronder)
createGiveaway({ messageId: message.id, channelId: message.channelId,
prize, winners, endsAt, participants: [] });
}
Het <t:...:R>-formaat is Discords ingebouwde relatieve tijdstempel; het toont automatisch "over 3 uur" in de tijdzone van elke gebruiker. Dat ziet er veel netter uit dan een vaste tekst.
Deelnemers verzamelen met een knop
Elke keer dat op de "Meedoen"-knop wordt geklikt, voegen we de gebruiker toe aan de deelnemerslijst van de giveaway. Om dezelfde persoon niet dubbel te tellen, behandel je de gebruikers-ID's als een verzameling (een Set in het geheugen of een uniek record in de DB):
client.on('interactionCreate', async (interaction) => {
if (!interaction.isButton()) return;
if (interaction.customId !== 'giveaway_join') return;
const giveaway = getGiveaway(interaction.message.id);
if (!giveaway || giveaway.ended) {
return interaction.reply({ content: 'Deze giveaway is niet meer actief.', ephemeral: true });
}
if (giveaway.participants.includes(interaction.user.id)) {
return interaction.reply({ content: 'Je doet al mee! 🎉', ephemeral: true });
}
addParticipant(giveaway.messageId, interaction.user.id);
await interaction.reply({ content: 'Je doet mee aan de giveaway, succes!', ephemeral: true });
});
Dankzij ephemeral: true zijn de bevestigingsberichten alleen zichtbaar voor wie geklikt heeft, zodat het kanaal niet volloopt met spam. Je kunt de embed bij elke deelname bijwerken om een live deelnemersteller te tonen; maar bij erg drukke giveaways kunnen frequente bewerkingen tegen rate limits aanlopen, dus is het gezonder om de teller periodiek te verversen (bijvoorbeeld elke 5 seconden).
De timer beheren: setTimeout en herstel
De eenvoudigste aanpak is een setTimeout instellen bij het starten van de giveaway en de afsluitfunctie aanroepen als de tijd om is. Maar setTimeout leeft alleen in het geheugen; als de bot herstart, is de timer weg. De robuuste oplossing heeft twee lagen:
- Bewaar de
endsAt-waarde van de giveaway persistent. - Lees bij het opstarten alle actieve giveaways en herplan op basis van de resterende tijd; sluit meteen alles af dat al verlopen is.
function scheduleEnd(giveaway) {
const delay = giveaway.endsAt - Date.now();
if (delay <= 0) return endGiveaway(giveaway.messageId);
// setTimeout kan geen vertragingen van meer dan ~24,8 dagen aan; voor
// lange giveaways is een periodieke controle (bijv. elke minuut scannen)
// beter.
setTimeout(() => endGiveaway(giveaway.messageId), delay);
}
client.once('ready', () => {
for (const g of getActiveGiveaways()) scheduleEnd(g);
});
Als je veel langlopende giveaways beheert, schaalt één cron-achtige lus die eenmaal per minuut draait en alles verwerkt wat verlopen is, beter dan een aparte setTimeout voor elke giveaway.
Een winnaar eerlijk kiezen
Als de tijd om is, kiezen we de winnaar(s) willekeurig uit de deelnemers. Voor eerlijkheid moet elke deelnemer een gelijke kans hebben en mag niemand twee keer worden geselecteerd. Een simpele shuffle handelt dit netjes af:
function pickWinners(participants, count) {
const pool = [...participants];
// Fisher-Yates-shuffle
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
return pool.slice(0, Math.min(count, pool.length));
}
async function endGiveaway(messageId) {
const giveaway = getGiveaway(messageId);
if (!giveaway || giveaway.ended) return;
markEnded(messageId);
const channel = await client.channels.fetch(giveaway.channelId);
const message = await channel.messages.fetch(messageId).catch(() => null);
const winners = pickWinners(giveaway.participants, giveaway.winners);
if (winners.length === 0) {
await channel.send(`🎉 De **${giveaway.prize}**-giveaway is afgelopen, maar niemand deed mee.`);
} else {
const mentions = winners.map(id => `<@${id}>`).join(', ');
await channel.send(`🎉 Gefeliciteerd ${mentions}! Je hebt **${giveaway.prize}** gewonnen.`);
}
// Knop uitschakelen
if (message) {
const disabled = ButtonBuilder.from(message.components[0].components[0]).setDisabled(true);
await message.edit({ components: [new ActionRowBuilder().addComponents(disabled)] }).catch(() => {});
}
}
Math.random() is niet ideaal voor een trekking die cryptografische veiligheid vereist, maar voor gewone community-giveaways is het ruim voldoende en volkomen eerlijk. Wanneer er geen winnaar kan worden getrokken (niemand deed mee), voorkom je verwarring bij leden door dat met een duidelijk bericht te melden.
Persistentie en opnieuw trekken
Het hart van het hele systeem is het persistent houden van de giveaway-status. Voor kleine bots is SQLite (better-sqlite3) ideaal: één bestand, geen installatiegedoe. Sla in een giveaways-tabel de kolommen messageId, channelId, prize, winnerCount, endsAt en ended op; in een aparte participants-tabel het paar messageId + userId met een unieke index. Zo los je het probleem "dezelfde persoon dubbel toevoegen" op databaseniveau op. Een "opnieuw trekken"-functie is op deze manier ook eenvoudig: als je de deelnemerslijst na afloop bewaart in plaats van te verwijderen, kan een admincommando een nieuwe winnaar uit dezelfde pool trekken.
Veelgestelde vragen
Waarom zou ik een knop gebruiken in plaats van een reactie?
Bij reactie-gebaseerde giveaways moet de bot alle reacties ophalen, wat traag en foutgevoelig is op grote servers; en valsspelen door de emoji weg te halen en terug te plaatsen is makkelijk. Bij deelname via een knop valideer je elke klik direct, houd je deelnemers in één bron (de DB) en blokkeer je dubbele deelnames definitief.
Wat gebeurt er met lopende giveaways als de bot uitvalt?
Zolang je de endsAt-waarde persistent bewaart, is er geen probleem. Wanneer de bot ready is, lees je de actieve giveaways en herplan je op basis van de resterende tijd; alles wat al verlopen is, wordt direct na het opstarten afgerond. Houd je alle status alleen in het geheugen, dan wist een herstart alles.
Hoe laat ik alleen leden met een bepaalde rol meedoen?
Voeg in de handler van de meedoen-knop een controle interaction.member.roles.cache.has(roleId) toe en weiger, als die faalt, de deelname met een ephemeral waarschuwing. Met dezelfde aanpak kun je voorwaarden afdwingen zoals een minimale accountleeftijd of lidmaatschapsduur op de server.
Wil je een solide giveaway-bot voor je server? Ik kan een kant-en-klare opzet bouwen met getimede giveaways, rolvereisten, opnieuw trekken en een persistente database. Neem contact op en laten we bespreken wat je nodig hebt.