Un bot de sondage Discord est l'un des outils les plus pratiques pour aider ta communauté à décider : tu saisis une question et des options en une seule commande, les membres votent en cliquant sur les boutons sous chaque option, et les résultats se mettent à jour en direct sous les yeux de tous. Dans ce guide, nous allons construire un système de vote basé sur les boutons à partir de zéro avec discord.js v14 : génération dynamique des options, prévention des votes en double, barres de résultats en direct avec pourcentages, et persistance pour que les sondages survivent à un redémarrage. Nous utiliserons des boutons plutôt que l'ancienne méthode par emoji (réaction), car ils sont plus propres, plus fiables et plus difficiles à manipuler.
Architecture d'un système de sondage
Au fond, un sondage suit deux choses : quelles options existent et qui a voté pour quelle option. Pour chaque sondage actif, tu dois stocker :
- L'ID du salon et du message du sondage (
messageId). - Le texte de la question et le tableau des options.
- Le propriétaire de chaque vote : une correspondance
userId→ index de l'option choisie. - Si le sondage est à choix unique ou multiple, plus une heure de fin facultative.
Si tu gardes cet état uniquement en mémoire (un Map), tous les sondages actifs disparaissent au redémarrage du bot. C'est pourquoi nous ajouterons plus tard un stockage persistant (comme SQLite) ; mais établissons d'abord la logique, puis branchons la persistance.
La commande slash qui lance le sondage
Nous lançons le sondage avec une commande slash. Comme Discord ne place que cinq composants au maximum dans une seule ActionRow (cinq boutons par ligne), tu peux en pratique mettre jusqu'à cinq options sur une ligne ; au-delà, tu ajoutes une seconde ligne. Ici, nous prenons des options séparées par des virgules et générons les boutons dynamiquement :
const {
SlashCommandBuilder,
EmbedBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} = require('discord.js');
const data = new SlashCommandBuilder()
.setName('sondage')
.setDescription('Lancer un nouveau sondage')
.addStringOption(o =>
o.setName('question').setDescription('Question du sondage').setRequired(true))
.addStringOption(o =>
o.setName('options').setDescription('Séparées par des virgules : Oui, Non, Peut-être').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: 'Au moins deux options sont requises.', 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: {} });
}
Nous intégrons l'index de l'option dans le customId de chaque bouton (poll_vote_0, poll_vote_1...). Ainsi, lors du traitement d'un clic, nous pouvons lire exactement quelle option l'utilisateur a choisie. Nous tronquons les libellés à 80 caractères car Discord limite la longueur des libellés de boutons.
Collecter les votes et empêcher les doublons
Chaque fois qu'un bouton est cliqué, nous enregistrons le vote de cet utilisateur. Point clé : si la même personne clique à nouveau, l'ancien vote doit être remplacé, pas ajouté, afin que le compte ne soit pas gonflé. Comme nous stockons les votes dans un objet indexé par userId, c'est résolu naturellement ; réécrire écrase la valeur précédente :
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: 'Ce sondage n\'est plus actif.', 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
? 'Ton vote était déjà sur cette option.'
: `Vote enregistré : **${poll.options[choice]}**`;
// Rafraîchir l'embed avec les derniers résultats
await interaction.update({
embeds: [buildPollEmbed(poll.question, poll.options, poll.votes)],
});
await interaction.followUp({ content: msg, ephemeral: true });
});
Ici, interaction.update() rafraîchit le message du sondage lui-même avec les derniers résultats ; ensuite followUp envoie une confirmation éphémère visible uniquement par le votant. Cela garde le salon exempt de messages de remerciement tout en maintenant les résultats en direct pour tous.
Afficher les résultats en direct
Le cœur d'un sondage est la présentation des résultats. Dans l'embed, nous afficherons, pour chaque option, le nombre de votes, le pourcentage et une simple barre dessinée à partir de texte. D'abord, comptons les votes, puis construisons la barre :
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); // barre à 10 blocs
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 des votes : ${total}` })
.setColor(0x5865f2);
}
La barre dessinée avec les caractères de bloc █ et ░ s'affiche à la même largeur sur toutes les plateformes et évite toute bibliothèque d'images externe. Calculer toujours le pourcentage par rapport au total est important ; nous nous protégeons d'une erreur de division lorsque total vaut zéro avec la condition total ? ... : 0.
Sondages à choix multiple et clôture d'un sondage
Jusqu'ici, chaque utilisateur pouvait choisir une seule option. Si tu veux un sondage à choix multiple (des votes sur plus d'une option), change la structure de données en userId → tableau d'index, et à chaque clic ajoute cette option au tableau ou retire-la (bascule). La version à choix unique suffit pour la plupart des sondages communautaires et rend les résultats plus faciles à interpréter.
Il y a deux façons de clôturer un sondage : proposer au propriétaire une commande « Terminer le sondage », ou lui donner une durée facultative et le fermer automatiquement à expiration. À la clôture, désactiver les boutons et figer le résultat final donne une fin propre :
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;
// Désactiver tous les boutons
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} (clôturé)`);
await message.edit({ embeds: [embed], components: disabledRows });
}
Persistance : rendre les sondages résistants au redémarrage
Le cœur de tout le système est de garder l'état du sondage persistant. Pour les petits bots, SQLite (better-sqlite3) est idéal : un seul fichier, aucun souci d'installation. Deux tables suffisent : une table polls avec messageId, channelId, question, options (au format texte JSON), ended ; et une table votes séparée stockant le couple messageId + userId avec un index UNIQUE, à côté d'une colonne choice. Grâce à l'index unique, le second vote d'un utilisateur est silencieusement écrasé via INSERT ... ON CONFLICT ; le problème des votes en double est résolu au niveau de la base de données. Quand le bot est ready, tu lis les sondages actifs et reconstitues le Map en mémoire, de sorte qu'aucun vote n'est perdu.
Questions fréquentes
Pourquoi utiliser des boutons plutôt que des réactions ?
Avec les sondages par réaction, le bot doit récupérer toutes les réactions, ce qui est lent et source d'erreurs sur les grands serveurs, et il est facile de tricher en retirant puis en remettant un emoji. Avec le vote par bouton, tu valides chaque clic instantanément, gardes les votes dans une source unique (la BDD) et bloques les votes en double définitivement. Tu lis aussi sans ambiguïté quelle option a été choisie grâce au customId.
Devrais-je utiliser la nouvelle fonctionnalité de sondage intégrée à Discord ?
Pour des sondages simples et rapides, le sondage intégré de Discord convient parfaitement. Mais si tu as besoin de restrictions par rôle, de journaliser les résultats dans un autre salon, de visuels de résultats personnalisés ou de stocker les données dans ta propre base, ton propre bot te donne un contrôle total. L'approche de ce guide vise précisément cette flexibilité.
Comment garder les votes privés ?
N'affiche que les totaux et les pourcentages dans l'embed et ne publie nulle part qui a voté pour quoi. Comme tu envoies les confirmations avec ephemeral: true, le choix d'un utilisateur n'est visible que par lui-même. Garde l'information « qui a voté pour quoi » uniquement dans la base de données et ne l'affiche jamais dans le salon.
Tu veux un bot de sondage solide pour ton serveur ? Je peux te préparer une installation clé en main avec vote par boutons, barres de résultats en direct, conditions de rôle et base de données persistante. Contacte-moi et parlons de tes besoins.