Ein Discord-Umfrage-Bot gehört zu den praktischsten Werkzeugen, um deine Community Entscheidungen treffen zu lassen: Du gibst mit einem Befehl eine Frage und Optionen ein, Mitglieder stimmen ab, indem sie auf die Schaltflächen unter jeder Option klicken, und die Ergebnisse werden live vor aller Augen aktualisiert. In dieser Anleitung bauen wir mit discord.js v14 ein schaltflächenbasiertes Abstimmungssystem von Grund auf: dynamische Erzeugung der Optionen, Schutz vor Doppelstimmen, Live-Ergebnisbalken mit Prozentwerten und Persistenz, damit Umfragen einen Neustart überstehen. Wir verwenden Schaltflächen statt der alten Emoji-Methode (Reaktionen), weil sie sauberer, zuverlässiger und schwerer zu manipulieren sind.
Architektur eines Umfragesystems
Im Kern verfolgt eine Umfrage zwei Dinge: welche Optionen es gibt und wer für welche Option gestimmt hat. Für jede aktive Umfrage musst du speichern:
- Die Kanal- und Nachrichten-ID der Umfragenachricht (
messageId). - Den Fragetext und das Array der Optionen.
- Den Besitzer jeder Stimme: eine Zuordnung
userId→ Index der gewählten Option. - Ob die Umfrage Einfach- oder Mehrfachauswahl ist, plus eine optionale Endzeit.
Hältst du diesen Zustand nur im Speicher (eine Map), verschwinden alle aktiven Umfragen beim Neustart des Bots. Deshalb fügen wir später einen persistenten Speicher (etwa SQLite) hinzu; aber zuerst legen wir die Logik fest und binden danach die Persistenz an.
Der Slash-Befehl, der die Umfrage startet
Wir starten die Umfrage mit einem Slash-Befehl. Da Discord höchstens fünf Komponenten in eine einzelne ActionRow packt (fünf Schaltflächen pro Zeile), kannst du in der Praxis bis zu fünf Optionen in eine Zeile setzen; für mehr fügst du eine zweite Zeile hinzu. Hier nehmen wir kommagetrennte Optionen und erzeugen die Schaltflächen dynamisch:
const {
SlashCommandBuilder,
EmbedBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} = require('discord.js');
const data = new SlashCommandBuilder()
.setName('umfrage')
.setDescription('Eine neue Umfrage starten')
.addStringOption(o =>
o.setName('frage').setDescription('Umfragefrage').setRequired(true))
.addStringOption(o =>
o.setName('optionen').setDescription('Mit Komma getrennt: Ja, Nein, Vielleicht').setRequired(true));
async function execute(interaction) {
const question = interaction.options.getString('frage');
const options = interaction.options.getString('optionen')
.split(',').map(s => s.trim()).filter(Boolean).slice(0, 5);
if (options.length < 2) {
return interaction.reply({ content: 'Mindestens zwei Optionen sind erforderlich.', ephemeral: true });
}
const row = new ActionRowBuilder().addComponents(
options.map((label, i) =>
new ButtonBuilder()
.setCustomId(`poll_vote_${i}`)
.setLabel(label.slice(0, 80))
.setStyle(ButtonStyle.Secondary))
);
const message = await interaction.reply({
embeds: [buildPollEmbed(question, options, {})],
components: [row],
fetchReply: true,
});
createPoll({ messageId: message.id, question, options, votes: {} });
}
Wir betten den Index der Option in die customId jeder Schaltfläche ein (poll_vote_0, poll_vote_1...). So können wir beim Verarbeiten eines Klicks genau auslesen, welche Option der Nutzer gewählt hat. Wir kürzen Beschriftungen auf 80 Zeichen, weil Discord die Länge von Schaltflächenbeschriftungen begrenzt.
Stimmen sammeln und Doppelstimmen verhindern
Jedes Mal, wenn eine Schaltfläche geklickt wird, erfassen wir die Stimme dieses Nutzers. Der entscheidende Punkt: Klickt dieselbe Person erneut, muss die alte Stimme ersetzt, nicht hinzugefügt werden, damit die Zählung nicht aufgebläht wird. Da wir Stimmen in einem nach userId indizierten Objekt speichern, löst sich das von selbst; erneutes Schreiben überschreibt den vorherigen Wert:
client.on('interactionCreate', async (interaction) => {
if (!interaction.isButton()) return;
if (!interaction.customId.startsWith('poll_vote_')) return;
const poll = getPoll(interaction.message.id);
if (!poll || poll.ended) {
return interaction.reply({ content: 'Diese Umfrage ist nicht mehr aktiv.', ephemeral: true });
}
const choice = parseInt(interaction.customId.split('_')[2], 10);
const previous = poll.votes[interaction.user.id];
setVote(poll.messageId, interaction.user.id, choice);
const msg = previous === choice
? 'Deine Stimme lag bereits auf dieser Option.'
: `Stimme erfasst: **${poll.options[choice]}**`;
// Embed mit den neuesten Ergebnissen aktualisieren
await interaction.update({
embeds: [buildPollEmbed(poll.question, poll.options, poll.votes)],
});
await interaction.followUp({ content: msg, ephemeral: true });
});
Hier aktualisiert interaction.update() die Umfragenachricht selbst mit den neuesten Ergebnissen; anschließend sendet followUp eine ephemere Bestätigung, die nur für den Abstimmenden sichtbar ist. So bleibt der Kanal frei von Dankes-Spam, während die Ergebnisse für alle live bleiben.
Ergebnisse live anzeigen
Das Herzstück einer Umfrage ist die Ergebnisdarstellung. Im Embed zeigen wir für jede Option die Stimmenzahl, den Prozentwert und einen einfachen, aus Text gezeichneten Balken. Zähle zuerst die Stimmen, baue dann den Balken:
function buildPollEmbed(question, options, votes) {
// votes: { userId: optionIndex }
const counts = options.map((_, i) =>
Object.values(votes).filter(v => v === i).length);
const total = counts.reduce((a, b) => a + b, 0);
const lines = options.map((label, i) => {
const count = counts[i];
const pct = total ? Math.round((count / total) * 100) : 0;
const filled = Math.round(pct / 10); // Balken mit 10 Blöcken
const bar = '█'.repeat(filled) + '░'.repeat(10 - filled);
return `**${label}**\n${bar} ${pct}% (${count})`;
});
return new EmbedBuilder()
.setTitle(`📊 ${question}`)
.setDescription(lines.join('\n\n'))
.setFooter({ text: `Stimmen gesamt: ${total}` })
.setColor(0x5865f2);
}
Der mit den Blockzeichen █ und ░ gezeichnete Balken wird auf jeder Plattform gleich breit dargestellt und kommt ohne externe Bildbibliothek aus. Den Prozentwert stets gegen die Gesamtzahl zu berechnen ist wichtig; wir verhindern einen Divisionsfehler, wenn total null ist, mit der Bedingung total ? ... : 0.
Mehrfachauswahl-Umfragen und das Schließen einer Umfrage
Bisher konnte jeder Nutzer eine einzelne Option wählen. Möchtest du eine Mehrfachauswahl-Umfrage (Stimmen für mehr als eine Option), ändere die Datenstruktur zu userId → Array von Indizes und füge bei jedem Klick diese Option zum Array hinzu oder entferne sie (Umschalten). Die Einfachauswahl-Variante reicht für die meisten Community-Umfragen und macht die Ergebnisse leichter interpretierbar.
Es gibt zwei Wege, eine Umfrage zu schließen: dem Besitzer einen Befehl „Umfrage beenden“ anbieten oder ihr eine optionale Dauer geben und sie automatisch schließen, wenn diese abläuft. Beim Schließen sorgen das Deaktivieren der Schaltflächen und das Einfrieren des Endergebnisses für einen sauberen Abschluss:
async function endPoll(messageId) {
const poll = getPoll(messageId);
if (!poll || poll.ended) return;
markEnded(messageId);
const channel = await client.channels.fetch(poll.channelId);
const message = await channel.messages.fetch(messageId).catch(() => null);
if (!message) return;
// Alle Schaltflächen deaktivieren
const disabledRows = message.components.map(row =>
new ActionRowBuilder().addComponents(
row.components.map(c => ButtonBuilder.from(c).setDisabled(true))));
const embed = buildPollEmbed(poll.question, poll.options, poll.votes)
.setTitle(`📊 ${poll.question} (geschlossen)`);
await message.edit({ embeds: [embed], components: disabledRows });
}
Persistenz: Umfragen neustartfest machen
Das Herzstück des gesamten Systems ist es, den Umfragezustand persistent zu halten. Für kleine Bots ist SQLite (better-sqlite3) ideal: eine Datei, kein Installationsaufwand. Zwei Tabellen genügen: eine polls-Tabelle mit messageId, channelId, question, options (als JSON-Text), ended; und eine separate votes-Tabelle, die das Paar messageId + userId mit einem UNIQUE-Index speichert, daneben eine Spalte choice. Dank des eindeutigen Index wird die zweite Stimme eines Nutzers über INSERT ... ON CONFLICT stillschweigend überschrieben; das Problem der Doppelstimmen wird auf Datenbankebene gelöst. Wenn der Bot ready ist, liest du die aktiven Umfragen und füllst die Map im Speicher neu, sodass keine Stimme verloren geht.
Häufige Fragen
Warum sollte ich Schaltflächen statt Reaktionen verwenden?
Bei reaktionsbasierten Umfragen muss der Bot alle Reaktionen abrufen, was auf großen Servern langsam und fehleranfällig ist, und es ist leicht zu schummeln, indem man ein Emoji entfernt und wieder hinzufügt. Beim Abstimmen per Schaltfläche validierst du jeden Klick sofort, hältst Stimmen in einer einzigen Quelle (der DB) und blockierst Doppelstimmen endgültig. Außerdem liest du über die customId eindeutig aus, welche Option gewählt wurde.
Sollte ich Discords neue eingebaute Umfragefunktion nutzen?
Für einfache, schnelle Umfragen ist Discords eingebaute Umfrage völlig in Ordnung. Doch wenn du rollenbasierte Einschränkungen, das Loggen von Ergebnissen in einen anderen Kanal, eigene Ergebnisgrafiken oder das Speichern der Daten in deiner eigenen Datenbank brauchst, gibt dir dein eigener Bot volle Kontrolle. Der Ansatz dieser Anleitung zielt genau auf diese Flexibilität ab.
Wie halte ich Stimmen privat?
Zeige im Embed nur die Summen und Prozentwerte und veröffentliche nirgends, wer für was gestimmt hat. Da du Bestätigungen mit ephemeral: true sendest, ist die Wahl eines Nutzers nur für ihn selbst sichtbar. Halte die Information „wer für was gestimmt hat“ ausschließlich in der Datenbank und gib sie niemals im Kanal aus.
Möchtest du einen soliden Umfrage-Bot für deinen Server? Ich kann dir eine schlüsselfertige Lösung mit Abstimmung per Schaltfläche, Live-Ergebnisbalken, Rollenvoraussetzungen und persistenter Datenbank bauen. Nimm Kontakt auf und lass uns über deine Anforderungen sprechen.