aslain.dev
0%
01 Hizmetler 02 Hakkımda 03 Projeler 04 Stack 05 Blog 06 İletişim
← Tüm makaleler Discord Bots

Discord Giveaway Bot: Timed Giveaways and Winner Picking

A Discord giveaway bot is one of the most practical tools for automating community events: you start a giveaway with a single command, members join with one click, and when the timer runs out the bot fairly picks the winner and announces it. In this article we'll build a timed giveaway system from scratch with discord.js v14: collecting participants through a button, a countdown, random winner selection, and persistence so giveaways survive a bot restart. Instead of the old reaction (emoji) method, we'll use buttons, because they are cleaner, more reliable and harder to manipulate.

Architecture of a giveaway system

At its core a giveaway has three states: start, collecting entries and ending. For every active giveaway you need to store:

  • The channel and message ID where the giveaway lives (messageId).
  • The prize text and the number of winners (winnerCount).
  • The end time (endsAt, always an absolute timestamp).
  • The user IDs of the participants.

If you keep this state only in memory (a Map), every restart wipes out all active giveaways. That's why a persistent store (like SQLite) is essential — but let's nail the logic first and add persistence afterwards.

The slash command that starts a giveaway

We start the giveaway with a slash command. Accepting the duration in a readable form such as 10m, 2h or 1d dramatically improves the user experience. First, a small helper that turns that text into milliseconds:

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];
}

The command itself takes the prize, the duration and the number of winners, then sends an embed together with a "Join" button:

const {
  SlashCommandBuilder,
  EmbedBuilder,
  ButtonBuilder,
  ButtonStyle,
  ActionRowBuilder,
} = require('discord.js');

const data = new SlashCommandBuilder()
  .setName('giveaway')
  .setDescription('Start a new giveaway')
  .addStringOption(o =>
    o.setName('prize').setDescription('Prize').setRequired(true))
  .addStringOption(o =>
    o.setName('duration').setDescription('e.g. 10m, 2h, 1d').setRequired(true))
  .addIntegerOption(o =>
    o.setName('winners').setDescription('Number of winners').setMinValue(1));

async function execute(interaction) {
  const prize = interaction.options.getString('prize');
  const ms = parseDuration(interaction.options.getString('duration'));
  const winners = interaction.options.getInteger('winners') ?? 1;
  if (!ms) {
    return interaction.reply({ content: 'Invalid duration format.', ephemeral: true });
  }

  const endsAt = Date.now() + ms;
  const embed = new EmbedBuilder()
    .setTitle('🎉 Giveaway')
    .setDescription(`**Prize:** ${prize}\n**Winners:** ${winners}\n**Ends:** <t:${Math.floor(endsAt / 1000)}:R>`)
    .setColor(0x5865f2);

  const join = new ButtonBuilder()
    .setCustomId('giveaway_join')
    .setLabel('Join')
    .setEmoji('🎉')
    .setStyle(ButtonStyle.Primary);

  const row = new ActionRowBuilder().addComponents(join);
  const message = await interaction.reply({
    embeds: [embed], components: [row], fetchReply: true,
  });

  // Save the giveaway (see the persistence section below)
  createGiveaway({ messageId: message.id, channelId: message.channelId,
    prize, winners, endsAt, participants: [] });
}

The <t:...:R> format is Discord's built-in relative timestamp; it renders as "in 3 hours" automatically in each user's own time zone. It looks far nicer than a hard-coded string.

Collecting participants with a button

Every time the "Join" button is clicked we add the user to the giveaway's participant list. To avoid counting the same person twice, treat the user IDs like a set (a Set in memory or a unique record in the 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: 'This giveaway is no longer active.', ephemeral: true });
  }

  if (giveaway.participants.includes(interaction.user.id)) {
    return interaction.reply({ content: 'You already joined! 🎉', ephemeral: true });
  }

  addParticipant(giveaway.messageId, interaction.user.id);
  await interaction.reply({ content: 'You joined the giveaway, good luck!', ephemeral: true });
});

Thanks to ephemeral: true the confirmation messages are only visible to the person who clicked, so the channel doesn't fill up with spam. You can update the embed on every join to show a live participant count; but on very busy giveaways frequent edits can hit rate limits, so it's healthier to refresh the counter periodically (say every 5 seconds).

Managing the timer: setTimeout and recovery

The simplest approach is to set a setTimeout when the giveaway starts and call the ending function when time is up. But setTimeout only lives in memory; if the bot restarts the timer is gone. The robust solution has two layers:

  • Persist the giveaway's endsAt value.
  • On startup, read all active giveaways and reschedule based on the remaining time; end immediately any that have already expired.
function scheduleEnd(giveaway) {
  const delay = giveaway.endsAt - Date.now();
  if (delay <= 0) return endGiveaway(giveaway.messageId);
  // setTimeout cannot hold delays longer than ~24.8 days; for long
  // giveaways a periodic check (e.g. a scan every minute) is preferable.
  setTimeout(() => endGiveaway(giveaway.messageId), delay);
}

client.once('ready', () => {
  for (const g of getActiveGiveaways()) scheduleEnd(g);
});

If you manage many long-running giveaways, a single cron-like loop that runs once a minute and processes everything that has expired scales better than a separate setTimeout for each one.

Picking a winner fairly

When time is up we pick the winner(s) randomly from the participants. For fairness every participant must have an equal chance and nobody should be selected twice. A simple shuffle handles this cleanly:

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(`🎉 The **${giveaway.prize}** giveaway ended, but nobody entered.`);
  } else {
    const mentions = winners.map(id => `<@${id}>`).join(', ');
    await channel.send(`🎉 Congratulations ${mentions}! You won **${giveaway.prize}**.`);
  }

  // Disable the button
  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 not ideal for a draw that requires cryptographic security, but for ordinary community giveaways it is more than enough and perfectly fair. When no winner can be drawn (nobody joined), stating it with a clear message keeps members from getting confused.

Persistence and rerolls

The heart of the whole system is keeping giveaway state persistent. For small bots SQLite (better-sqlite3) is ideal: a single file, no setup hassle. In a giveaways table store messageId, channelId, prize, winnerCount, endsAt and ended columns; in a separate participants table store the messageId + userId pair with a unique index. That solves the "add the same person twice" problem at the database level. A "reroll" feature is easy this way too: if you keep the participant list after the giveaway ends instead of deleting it, an admin command can draw a fresh winner from the same pool.

Frequently Asked Questions

Why should I use a button instead of a reaction?

With reaction-based giveaways the bot has to fetch all the reactions, which is slow and error-prone on large servers; and cheating by removing and re-adding the emoji is easy. With button entry you validate each click instantly, keep participants in a single source (the DB) and block duplicate entries for good.

What happens to running giveaways if the bot goes down?

As long as you persist the endsAt value there's no problem. When the bot is ready you read the active giveaways and reschedule based on the remaining time; any that already expired are concluded the moment it comes back up. If you keep all state in memory only, a restart wipes everything.

How do I let only members with a certain role join?

In the join button's handler add a interaction.member.roles.cache.has(roleId) check and, if it fails, reject the entry with an ephemeral warning. The same approach lets you enforce conditions like a minimum account age or server tenure.

Want a solid giveaway bot for your server? I can build a turnkey setup with timed giveaways, role requirements, rerolls and a persistent database. Get in touch and let's talk about what you need.

Bu kategorideki tüm yazılar →

Devamı için