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

Discord-Gewinnspiel-Bot: zeitgesteuerte Verlosungen und Gewinner

Ein Discord-Gewinnspiel-Bot gehört zu den praktischsten Werkzeugen, um Events auf deinem Server zu automatisieren: Du startest ein Gewinnspiel mit einem einzigen Befehl, Mitglieder nehmen mit einem Klick teil, und wenn der Countdown abläuft, wählt der Bot fair den Gewinner aus und verkündet ihn. In diesem Artikel bauen wir von Grund auf ein zeitgesteuertes Gewinnspielsystem mit discord.js v14: Teilnehmer über einen Button sammeln, ein Countdown, zufällige Gewinnerauswahl und Persistenz, damit Gewinnspiele einen Neustart des Bots überleben. Statt der alten Reaktions- (Emoji-)Methode verwenden wir Buttons, weil sie sauberer, zuverlässiger und schwerer zu manipulieren sind.

Architektur eines Gewinnspielsystems

Ein Gewinnspiel hat im Kern drei Zustände: Start, Teilnahmen sammeln und Beenden. Für jedes aktive Gewinnspiel musst du Folgendes speichern:

  • Den Kanal und die Nachrichten-ID, in der das Gewinnspiel liegt (messageId).
  • Den Preistext und die Anzahl der Gewinner (winnerCount).
  • Die Endzeit (endsAt, immer ein absoluter Zeitstempel).
  • Die Benutzer-IDs der Teilnehmer.

Hältst du diesen Zustand nur im Speicher (einer Map), löscht jeder Neustart alle aktiven Gewinnspiele. Deshalb ist ein persistenter Speicher (wie SQLite) unverzichtbar — doch klären wir zuerst die Logik und ergänzen danach die Persistenz.

Der Slash-Befehl, der ein Gewinnspiel startet

Wir starten das Gewinnspiel mit einem Slash-Befehl. Die Dauer in lesbarer Form wie 10m, 2h oder 1d entgegenzunehmen, verbessert das Nutzererlebnis erheblich. Zuerst ein kleiner Helfer, der diesen Text in Millisekunden umwandelt:

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

Der Befehl selbst nimmt den Preis, die Dauer und die Anzahl der Gewinner entgegen und sendet anschließend ein Embed zusammen mit einem „Teilnehmen"-Button:

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

const data = new SlashCommandBuilder()
  .setName('gewinnspiel')
  .setDescription('Ein neues Gewinnspiel starten')
  .addStringOption(o =>
    o.setName('preis').setDescription('Preis').setRequired(true))
  .addStringOption(o =>
    o.setName('dauer').setDescription('z. B. 10m, 2h, 1d').setRequired(true))
  .addIntegerOption(o =>
    o.setName('gewinner').setDescription('Anzahl der Gewinner').setMinValue(1));

async function execute(interaction) {
  const prize = interaction.options.getString('preis');
  const ms = parseDuration(interaction.options.getString('dauer'));
  const winners = interaction.options.getInteger('gewinner') ?? 1;
  if (!ms) {
    return interaction.reply({ content: 'Ungültiges Dauerformat.', ephemeral: true });
  }

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

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

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

  // Gewinnspiel speichern (siehe Abschnitt Persistenz unten)
  createGiveaway({ messageId: message.id, channelId: message.channelId,
    prize, winners, endsAt, participants: [] });
}

Das Format <t:...:R> ist Discords integrierter relativer Zeitstempel; er wird automatisch als „in 3 Stunden" in der jeweiligen Zeitzone jedes Nutzers angezeigt. Das wirkt deutlich eleganter als ein fest verdrahteter Text.

Teilnehmer mit einem Button sammeln

Jedes Mal, wenn der „Teilnehmen"-Button geklickt wird, fügen wir den Benutzer zur Teilnehmerliste des Gewinnspiels hinzu. Damit dieselbe Person nicht doppelt gezählt wird, behandle die Benutzer-IDs wie eine Menge (ein Set im Speicher oder ein eindeutiger Datensatz in der 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: 'Dieses Gewinnspiel ist nicht mehr aktiv.', ephemeral: true });
  }

  if (giveaway.participants.includes(interaction.user.id)) {
    return interaction.reply({ content: 'Du nimmst bereits teil! 🎉', ephemeral: true });
  }

  addParticipant(giveaway.messageId, interaction.user.id);
  await interaction.reply({ content: 'Du nimmst am Gewinnspiel teil, viel Glück!', ephemeral: true });
});

Dank ephemeral: true sind die Bestätigungsnachrichten nur für die klickende Person sichtbar, sodass der Kanal nicht mit Spam vollläuft. Du kannst das Embed bei jeder Teilnahme aktualisieren, um einen Live-Teilnehmerzähler anzuzeigen; bei sehr stark frequentierten Gewinnspielen können häufige Bearbeitungen jedoch in die Rate-Limits laufen, daher ist es sinnvoller, den Zähler periodisch (etwa alle 5 Sekunden) zu aktualisieren.

Den Timer verwalten: setTimeout und Wiederherstellung

Der einfachste Ansatz ist, beim Start des Gewinnspiels ein setTimeout zu setzen und die Beenden-Funktion aufzurufen, wenn die Zeit abgelaufen ist. Aber setTimeout lebt nur im Speicher; startet der Bot neu, ist der Timer weg. Die robuste Lösung hat zwei Schichten:

  • Speichere den endsAt-Wert des Gewinnspiels persistent.
  • Lies beim Start alle aktiven Gewinnspiele und plane anhand der Restzeit neu; beende sofort alle bereits abgelaufenen.
function scheduleEnd(giveaway) {
  const delay = giveaway.endsAt - Date.now();
  if (delay <= 0) return endGiveaway(giveaway.messageId);
  // setTimeout kann keine Verzögerungen über ~24,8 Tage halten; bei
  // langen Gewinnspielen ist eine periodische Prüfung (z. B. ein Scan
  // pro Minute) vorzuziehen.
  setTimeout(() => endGiveaway(giveaway.messageId), delay);
}

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

Verwaltest du viele langlaufende Gewinnspiele, skaliert eine einzige cron-artige Schleife, die einmal pro Minute läuft und alles Abgelaufene verarbeitet, besser als ein separates setTimeout für jedes einzelne.

Einen Gewinner fair auswählen

Wenn die Zeit abgelaufen ist, wählen wir den oder die Gewinner zufällig aus den Teilnehmern. Für Fairness muss jeder Teilnehmer die gleiche Chance haben und niemand darf zweimal ausgewählt werden. Ein einfaches Mischen (Shuffle) erledigt das sauber:

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(`🎉 Das **${giveaway.prize}**-Gewinnspiel ist beendet, aber niemand hat teilgenommen.`);
  } else {
    const mentions = winners.map(id => `<@${id}>`).join(', ');
    await channel.send(`🎉 Herzlichen Glückwunsch ${mentions}! Du hast **${giveaway.prize}** gewonnen.`);
  }

  // Button deaktivieren
  if (message) {
    const disabled = ButtonBuilder.from(message.components[0].components[0]).setDisabled(true);
    await message.edit({ components: [new ActionRowBuilder().addComponents(disabled)] }).catch(() => {});
  }
}

Math.random() ist für eine Ziehung, die kryptografische Sicherheit erfordert, nicht ideal; für gewöhnliche Community-Gewinnspiele reicht es jedoch mehr als aus und ist vollkommen fair. Wenn kein Gewinner gezogen werden kann (niemand teilgenommen hat), verhinderst du mit einer klaren Nachricht Verwirrung bei den Mitgliedern.

Persistenz und erneute Ziehung

Das Herzstück des gesamten Systems ist, den Gewinnspielzustand persistent zu halten. Für kleine Bots ist SQLite (better-sqlite3) ideal: eine einzige Datei, kein Installationsaufwand. Speichere in einer giveaways-Tabelle die Spalten messageId, channelId, prize, winnerCount, endsAt und ended; in einer separaten participants-Tabelle das Paar messageId + userId mit einem eindeutigen Index. Damit löst du das Problem „dieselbe Person doppelt hinzufügen" auf Datenbankebene. Eine „erneute Ziehung" ist so ebenfalls leicht: Behältst du die Teilnehmerliste nach Ende des Gewinnspiels bei, statt sie zu löschen, kann ein Admin-Befehl einen neuen Gewinner aus demselben Pool ziehen.

Häufige Fragen

Warum sollte ich einen Button statt einer Reaktion verwenden?

Bei reaktionsbasierten Gewinnspielen muss der Bot alle Reaktionen abrufen, was auf großen Servern langsam und fehleranfällig ist; und Betrug durch Entfernen und erneutes Setzen des Emojis ist einfach. Bei Button-Teilnahme validierst du jeden Klick sofort, hältst Teilnehmer in einer einzigen Quelle (der DB) und blockierst Doppelteilnahmen endgültig.

Was passiert mit laufenden Gewinnspielen, wenn der Bot ausfällt?

Solange du den endsAt-Wert persistent speicherst, gibt es kein Problem. Wenn der Bot ready ist, liest du die aktiven Gewinnspiele und planst anhand der Restzeit neu; bereits abgelaufene werden direkt nach dem Hochfahren abgeschlossen. Hältst du den gesamten Zustand nur im Speicher, löscht ein Neustart alles.

Wie lasse ich nur Mitglieder mit einer bestimmten Rolle teilnehmen?

Füge im Handler des Teilnehmen-Buttons eine Prüfung interaction.member.roles.cache.has(roleId) hinzu und lehne die Teilnahme, falls sie fehlschlägt, mit einer ephemeren Warnung ab. Mit demselben Ansatz kannst du Bedingungen wie ein Mindest-Kontoalter oder eine Mindestzugehörigkeit auf dem Server erzwingen.

Möchtest du einen soliden Gewinnspiel-Bot für deinen Server? Ich kann eine schlüsselfertige Lösung mit zeitgesteuerten Gewinnspielen, Rollenbedingungen, erneuter Ziehung und einer persistenten Datenbank bauen. Kontaktiere mich und lass uns über deinen Bedarf sprechen.

Bu kategorideki tüm yazılar →

Devamı için