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: Button Voting and Live Results

A Discord poll bot is one of the handiest tools for helping your community make decisions: you enter a question and options with a single command, members vote by clicking the buttons under each option, and the results update live for everyone to see. In this guide we will build a button-based voting system from scratch with discord.js v14: dynamic option generation, duplicate-vote prevention, live result bars with percentages, and persistence so polls survive a restart. We will use buttons instead of the old emoji (reaction) method because they are cleaner, more reliable, and harder to game.

Architecture of a poll system

At its core a poll tracks two things: which options exist and who voted for which option. For each active poll you need to store:

  • The channel and message ID of the poll message (messageId).
  • The question text and the array of options.
  • The owner of each vote: a userId → chosen option index mapping.
  • Whether the poll is single- or multi-choice, plus an optional end time.

If you keep this state in memory only (a Map), every active poll disappears when the bot restarts. That is why we will add a persistent store (such as SQLite) later; but first let us nail down the logic, then wire up persistence.

The slash command that starts the poll

We start the poll with a slash command. Since Discord fits at most five components into a single ActionRow (five buttons per row), you can put up to five options on one row in practice; for more you add a second row. Here we take comma-separated options and generate the buttons dynamically:

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

const data = new SlashCommandBuilder()
  .setName('poll')
  .setDescription('Start a new poll')
  .addStringOption(o =>
    o.setName('question').setDescription('Poll question').setRequired(true))
  .addStringOption(o =>
    o.setName('options').setDescription('Comma separated: Yes, No, Maybe').setRequired(true));

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

  if (options.length < 2) {
    return interaction.reply({ content: 'At least two options are required.', 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 embed the option index in each button's customId (poll_vote_0, poll_vote_1...). That way, when we handle a click, we can read exactly which option the user chose. We trim labels to 80 characters because Discord caps the length of button labels.

Collecting votes and preventing duplicates

Every time a button is clicked we record that user's vote. The key point: if the same person clicks again, the old vote must be replaced, not added, so the count does not inflate. Because we store votes in an object keyed by userId, this is solved naturally; writing again overwrites the previous value:

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: 'This poll is no longer active.', 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
    ? 'Your vote was already on this option.'
    : `Vote recorded: **${poll.options[choice]}**`;

  // Refresh the embed with the latest results
  await interaction.update({
    embeds: [buildPollEmbed(poll.question, poll.options, poll.votes)],
  });
  await interaction.followUp({ content: msg, ephemeral: true });
});

Here interaction.update() refreshes the poll message itself with the latest results; then followUp sends an ephemeral confirmation visible only to the voter. This keeps the channel free of acknowledgement spam while the results stay live for everyone.

Showing results live

The heart of a poll is the result presentation. Inside the embed we will show, for each option, the vote count, the percentage, and a simple bar drawn from text. First count the votes, then build the bar:

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);          // 10-block bar
    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: `Total votes: ${total}` })
    .setColor(0x5865f2);
}

The bar drawn with the and block characters renders at the same width on every platform and avoids any external image library. Always computing the percentage against the total is important; we guard against a division error when total is zero with the total ? ... : 0 condition.

Multi-choice polls and closing a poll

So far each user could pick a single option. If you want a multi-choice poll (votes on more than one option), change the data structure to userIdarray of indices, and on each click add that option to the array or remove it (toggle). The single-choice version is enough for most community polls and makes the results easier to interpret.

There are two ways to close a poll: offer the poll owner an "End poll" command, or give it an optional duration and close it automatically when it expires. When closing, disabling the buttons and freezing the final result gives a clean ending:

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;

  // Disable all of the buttons
  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} (closed)`);

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

Persistence: making polls survive a restart

The heart of the whole system is keeping poll state persistent. For small bots SQLite (better-sqlite3) is ideal: one file, no setup hassle. Two tables are enough: a polls table with messageId, channelId, question, options (as JSON text), ended; and a separate votes table storing the pair messageId + userId with a UNIQUE index, alongside a choice column. Thanks to the unique index, a user's second vote is silently overwritten via INSERT ... ON CONFLICT; the duplicate-vote problem is solved at the database level. When the bot is ready, you read the active polls and repopulate the in-memory Map, so no vote is lost.

Frequently Asked Questions

Why should I use buttons instead of reactions?

With reaction-based polls the bot has to fetch all the reactions, which is slow and error-prone on large servers, and it is easy to cheat by removing and re-adding an emoji. With button voting you validate each click instantly, keep votes in a single source (the DB), and block duplicate votes for good. You also read which option was chosen unambiguously through the customId.

Should I use Discord's new built-in poll feature?

For simple, quick polls Discord's built-in poll is perfectly fine. But if you need role-based restrictions, logging results to another channel, custom result visuals, or storing the data in your own database, your own bot gives you full control. The approach in this guide is aimed precisely at that flexibility.

How do I keep votes private?

Show only the totals and percentages in the embed and never publish who voted for what anywhere. Because you send confirmations with ephemeral: true, a user's choice is visible only to themselves. Keep the "who voted for what" data in the database only and never surface it in the channel.

Want a solid poll bot for your server? I can build a turnkey setup with button voting, live result bars, role requirements, and a persistent database. Get in touch and let us talk about what you need.

Bu kategorideki tüm yazılar →

Devamı için