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

Discord Poll-bot: stemmen met knoppen en live resultaten

Een Discord poll-bot is een van de handigste hulpmiddelen om je community te laten beslissen: je voert met één commando een vraag en opties in, leden stemmen door op de knoppen onder elke optie te klikken, en de resultaten worden live bijgewerkt waar iedereen bij is. In deze gids bouwen we vanaf nul een stemsysteem op basis van knoppen met discord.js v14: dynamische generatie van opties, het voorkomen van dubbele stemmen, live resultaatbalken met percentages, en persistentie zodat polls een herstart overleven. We gebruiken knoppen in plaats van de oude emoji-methode (reacties), omdat ze schoner, betrouwbaarder en moeilijker te manipuleren zijn.

Architectuur van een poll-systeem

In de kern houdt een poll twee dingen bij: welke opties er zijn en wie op welke optie heeft gestemd. Voor elke actieve poll moet je opslaan:

  • De kanaal- en bericht-ID van het poll-bericht (messageId).
  • De vraagtekst en de array met opties.
  • De eigenaar van elke stem: een koppeling userId → index van de gekozen optie.
  • Of de poll enkel- of meerkeuze is, plus een optionele eindtijd.

Als je deze toestand alleen in het geheugen houdt (een Map), verdwijnen alle actieve polls zodra de bot herstart. Daarom voegen we later een persistente opslag toe (zoals SQLite); maar laten we eerst de logica vastzetten en daarna de persistentie aansluiten.

Het slash-commando dat de poll start

We starten de poll met een slash-commando. Omdat Discord maximaal vijf componenten in één ActionRow plaatst (vijf knoppen per rij), kun je in de praktijk tot vijf opties op één rij zetten; voor meer voeg je een tweede rij toe. Hier nemen we door komma's gescheiden opties en genereren we de knoppen dynamisch:

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

const data = new SlashCommandBuilder()
  .setName('poll')
  .setDescription('Start een nieuwe poll')
  .addStringOption(o =>
    o.setName('vraag').setDescription('Poll-vraag').setRequired(true))
  .addStringOption(o =>
    o.setName('opties').setDescription('Komma-gescheiden: Ja, Nee, Misschien').setRequired(true));

async function execute(interaction) {
  const question = interaction.options.getString('vraag');
  const options = interaction.options.getString('opties')
    .split(',').map(s => s.trim()).filter(Boolean).slice(0, 5);

  if (options.length < 2) {
    return interaction.reply({ content: 'Minstens twee opties zijn vereist.', 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: {} });
}

We stoppen de index van de optie in de customId van elke knop (poll_vote_0, poll_vote_1...). Zo kunnen we bij het verwerken van een klik precies aflezen welke optie de gebruiker koos. We korten labels in tot 80 tekens, omdat Discord de lengte van knoplabels beperkt.

Stemmen verzamelen en dubbele stemmen voorkomen

Elke keer dat er op een knop wordt geklikt, registreren we de stem van die gebruiker. Het cruciale punt: als dezelfde persoon nogmaals klikt, moet de oude stem worden vervangen, niet toegevoegd, zodat de telling niet opgeblazen raakt. Omdat we stemmen opslaan in een object met userId als sleutel, lost dit zichzelf op; opnieuw schrijven overschrijft de vorige waarde:

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: 'Deze poll is niet meer actief.', 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
    ? 'Je stem stond al op deze optie.'
    : `Stem geregistreerd: **${poll.options[choice]}**`;

  // Vernieuw de embed met de laatste resultaten
  await interaction.update({
    embeds: [buildPollEmbed(poll.question, poll.options, poll.votes)],
  });
  await interaction.followUp({ content: msg, ephemeral: true });
});

Hier vernieuwt interaction.update() het poll-bericht zelf met de laatste resultaten; vervolgens stuurt followUp een ephemeral bevestiging die alleen voor de stemmer zichtbaar is. Zo blijft het kanaal vrij van bedank-spam terwijl de resultaten voor iedereen live blijven.

Resultaten live tonen

Het hart van een poll is de presentatie van de resultaten. In de embed tonen we voor elke optie het aantal stemmen, het percentage en een eenvoudige balk getekend uit tekst. Tel eerst de stemmen, bouw daarna de balk:

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);          // balk van 10 blokken
    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: `Totaal aantal stemmen: ${total}` })
    .setColor(0x5865f2);
}

De balk die met de blok-tekens en getekend is, wordt op elk platform even breed weergegeven en vermijdt elke externe afbeeldingsbibliotheek. Het percentage altijd ten opzichte van het totaal berekenen is belangrijk; we beschermen tegen een delingsfout wanneer total nul is met de voorwaarde total ? ... : 0.

Meerkeuze-polls en een poll sluiten

Tot nu toe kon elke gebruiker één optie kiezen. Wil je een meerkeuze-poll (stemmen op meer dan één optie), verander dan de datastructuur in userIdarray van indexen, en voeg bij elke klik die optie toe aan de array of haal hem eruit (toggle). De enkelvoudige versie volstaat voor de meeste community-polls en maakt de resultaten makkelijker te interpreteren.

Er zijn twee manieren om een poll te sluiten: bied de eigenaar een commando "Poll beëindigen" aan, of geef hem een optionele duur en sluit hem automatisch wanneer die verstrijkt. Bij het sluiten geven het uitschakelen van de knoppen en het vastzetten van het eindresultaat een nette afsluiting:

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;

  // Schakel alle knoppen uit
  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} (gesloten)`);

  await message.edit({ embeds: [embed], components: disabledRows });
}

Persistentie: polls bestand maken tegen een herstart

Het hart van het hele systeem is het persistent houden van de poll-toestand. Voor kleine bots is SQLite (better-sqlite3) ideaal: één bestand, geen installatiegedoe. Twee tabellen volstaan: een polls-tabel met messageId, channelId, question, options (als JSON-tekst), ended; en een aparte votes-tabel die het paar messageId + userId opslaat met een UNIQUE-index, naast een kolom choice. Dankzij de unieke index wordt de tweede stem van een gebruiker stil overschreven via INSERT ... ON CONFLICT; het probleem van dubbele stemmen is op databaseniveau opgelost. Wanneer de bot ready is, lees je de actieve polls en vul je de Map in het geheugen opnieuw, zodat geen enkele stem verloren gaat.

Veelgestelde vragen

Waarom zou ik knoppen gebruiken in plaats van reacties?

Bij polls op basis van reacties moet de bot alle reacties ophalen, wat traag en foutgevoelig is op grote servers, en het is eenvoudig om vals te spelen door een emoji te verwijderen en opnieuw toe te voegen. Bij stemmen met knoppen valideer je elke klik direct, houd je stemmen in één bron (de DB) en blokkeer je dubbele stemmen definitief. Je leest ook ondubbelzinnig af welke optie is gekozen via de customId.

Moet ik Discords nieuwe ingebouwde poll-functie gebruiken?

Voor eenvoudige, snelle polls is Discords ingebouwde poll prima. Maar als je rol-gebaseerde beperkingen, het loggen van resultaten naar een ander kanaal, aangepaste resultaatvisuals of het opslaan van de data in je eigen database nodig hebt, geeft je eigen bot je volledige controle. De aanpak in deze gids is precies op die flexibiliteit gericht.

Hoe houd ik stemmen privé?

Toon alleen de totalen en percentages in de embed en publiceer nergens wie op wat heeft gestemd. Omdat je bevestigingen verstuurt met ephemeral: true, is de keuze van een gebruiker alleen voor hemzelf zichtbaar. Houd de informatie "wie stemde op wat" alleen in de database en laat die nooit in het kanaal zien.

Wil je een solide poll-bot voor je server? Ik kan een kant-en-klare opzet bouwen met stemmen via knoppen, live resultaatbalken, rolvereisten en een persistente database. Neem contact op en laten we bespreken wat je nodig hebt.

Bu kategorideki tüm yazılar →

Devamı için