Un bot de tirage au sort Discord est l'un des outils les plus pratiques pour automatiser les événements de ta communauté : tu lances un tirage avec une seule commande, les membres participent en un clic, et une fois le compte à rebours terminé, le bot choisit équitablement le gagnant et l'annonce. Dans cet article, nous allons construire de zéro un système de tirage minuté avec discord.js v14 : collecte des participants via un bouton, compte à rebours, sélection aléatoire du gagnant et persistance pour que les tirages survivent à un redémarrage du bot. Au lieu de l'ancienne méthode par réaction (emoji), nous utiliserons des boutons, car ils sont plus propres, plus fiables et plus difficiles à manipuler.
Architecture d'un système de tirage
Un tirage comporte essentiellement trois états : démarrage, collecte des participations et clôture. Pour chaque tirage actif, tu dois stocker :
- Le salon et l'identifiant du message où se trouve le tirage (
messageId). - Le texte du lot et le nombre de gagnants (
winnerCount). - L'heure de fin (
endsAt, toujours un horodatage absolu). - Les identifiants des participants.
Si tu conserves cet état uniquement en mémoire (une Map), chaque redémarrage efface tous les tirages actifs. C'est pourquoi un stockage persistant (comme SQLite) est indispensable — mais clarifions d'abord la logique, puis ajoutons la persistance.
La commande slash qui lance un tirage
Nous lançons le tirage avec une commande slash. Accepter la durée sous une forme lisible comme 10m, 2h ou 1d améliore considérablement l'expérience. D'abord, une petite fonction utilitaire qui convertit ce texte en millisecondes :
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];
}
La commande elle-même prend le lot, la durée et le nombre de gagnants, puis envoie un embed accompagné d'un bouton « Participer » :
const {
SlashCommandBuilder,
EmbedBuilder,
ButtonBuilder,
ButtonStyle,
ActionRowBuilder,
} = require('discord.js');
const data = new SlashCommandBuilder()
.setName('tirage')
.setDescription('Lancer un nouveau tirage')
.addStringOption(o =>
o.setName('lot').setDescription('Lot').setRequired(true))
.addStringOption(o =>
o.setName('duree').setDescription('ex. : 10m, 2h, 1d').setRequired(true))
.addIntegerOption(o =>
o.setName('gagnants').setDescription('Nombre de gagnants').setMinValue(1));
async function execute(interaction) {
const prize = interaction.options.getString('lot');
const ms = parseDuration(interaction.options.getString('duree'));
const winners = interaction.options.getInteger('gagnants') ?? 1;
if (!ms) {
return interaction.reply({ content: 'Format de durée invalide.', ephemeral: true });
}
const endsAt = Date.now() + ms;
const embed = new EmbedBuilder()
.setTitle('🎉 Tirage au sort')
.setDescription(`**Lot :** ${prize}\n**Gagnants :** ${winners}\n**Fin :** <t:${Math.floor(endsAt / 1000)}:R>`)
.setColor(0x5865f2);
const join = new ButtonBuilder()
.setCustomId('giveaway_join')
.setLabel('Participer')
.setEmoji('🎉')
.setStyle(ButtonStyle.Primary);
const row = new ActionRowBuilder().addComponents(join);
const message = await interaction.reply({
embeds: [embed], components: [row], fetchReply: true,
});
// Enregistrer le tirage (voir la section persistance ci-dessous)
createGiveaway({ messageId: message.id, channelId: message.channelId,
prize, winners, endsAt, participants: [] });
}
Le format <t:...:R> est l'horodatage relatif intégré de Discord ; il s'affiche automatiquement « dans 3 heures » selon le fuseau horaire de chaque utilisateur. C'est bien plus élégant qu'un texte figé.
Collecter les participants avec un bouton
À chaque clic sur le bouton « Participer », nous ajoutons l'utilisateur à la liste des participants du tirage. Pour éviter de compter la même personne deux fois, traite les identifiants comme un ensemble (un Set en mémoire ou un enregistrement unique en base) :
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: 'Ce tirage n\'est plus actif.', ephemeral: true });
}
if (giveaway.participants.includes(interaction.user.id)) {
return interaction.reply({ content: 'Tu participes déjà ! 🎉', ephemeral: true });
}
addParticipant(giveaway.messageId, interaction.user.id);
await interaction.reply({ content: 'Tu participes au tirage, bonne chance !', ephemeral: true });
});
Grâce à ephemeral: true, les messages de confirmation ne sont visibles que par la personne qui a cliqué, ce qui évite de polluer le salon. Tu peux mettre à jour l'embed à chaque participation pour afficher un compteur en direct ; mais sur les tirages très fréquentés, des modifications fréquentes peuvent atteindre les rate limits, donc il est plus sain de rafraîchir le compteur périodiquement (par exemple toutes les 5 secondes).
Gérer le minuteur : setTimeout et récupération
L'approche la plus simple consiste à poser un setTimeout au démarrage du tirage et à appeler la fonction de clôture une fois le temps écoulé. Mais setTimeout ne vit qu'en mémoire ; si le bot redémarre, le minuteur disparaît. La solution robuste comporte deux couches :
- Persiste la valeur
endsAtdu tirage. - Au démarrage, lis tous les tirages actifs et reprogramme selon le temps restant ; clôture immédiatement ceux déjà expirés.
function scheduleEnd(giveaway) {
const delay = giveaway.endsAt - Date.now();
if (delay <= 0) return endGiveaway(giveaway.messageId);
// setTimeout ne supporte pas les délais de plus de ~24,8 jours ; pour
// les longs tirages, une vérification périodique (un scan par minute)
// est préférable.
setTimeout(() => endGiveaway(giveaway.messageId), delay);
}
client.once('ready', () => {
for (const g of getActiveGiveaways()) scheduleEnd(g);
});
Si tu gères de nombreux tirages longs, une seule boucle de type cron exécutée une fois par minute, traitant tout ce qui a expiré, s'adapte mieux qu'un setTimeout distinct pour chacun.
Choisir un gagnant équitablement
Une fois le temps écoulé, nous choisissons le(s) gagnant(s) au hasard parmi les participants. Pour l'équité, chaque participant doit avoir une chance égale et personne ne doit être sélectionné deux fois. Un simple mélange (shuffle) gère cela proprement :
function pickWinners(participants, count) {
const pool = [...participants];
// Mélange de Fisher-Yates
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(`🎉 Le tirage **${giveaway.prize}** est terminé, mais personne n'a participé.`);
} else {
const mentions = winners.map(id => `<@${id}>`).join(', ');
await channel.send(`🎉 Félicitations ${mentions} ! Tu as gagné **${giveaway.prize}**.`);
}
// Désactiver le bouton
if (message) {
const disabled = ButtonBuilder.from(message.components[0].components[0]).setDisabled(true);
await message.edit({ components: [new ActionRowBuilder().addComponents(disabled)] }).catch(() => {});
}
}
Math.random() n'est pas idéal pour un tirage nécessitant une sécurité cryptographique, mais pour des tirages communautaires ordinaires, il est largement suffisant et parfaitement équitable. Lorsqu'aucun gagnant ne peut être tiré (personne n'a participé), l'indiquer par un message clair évite de semer la confusion chez les membres.
Persistance et nouveau tirage
Le cœur de tout le système est de garder l'état du tirage persistant. Pour les petits bots, SQLite (better-sqlite3) est idéal : un seul fichier, aucune installation compliquée. Dans une table giveaways, stocke les colonnes messageId, channelId, prize, winnerCount, endsAt et ended ; dans une table participants séparée, stocke le couple messageId + userId avec un index unique. Cela résout le problème du « même participant ajouté deux fois » au niveau de la base de données. La fonction « nouveau tirage » devient également facile : si tu conserves la liste des participants après la fin du tirage au lieu de la supprimer, une commande d'administration peut tirer un nouveau gagnant dans le même pool.
Questions fréquentes
Pourquoi utiliser un bouton plutôt qu'une réaction ?
Avec les tirages basés sur les réactions, le bot doit récupérer toutes les réactions, ce qui est lent et source d'erreurs sur les grands serveurs ; et tricher en retirant puis remettant l'emoji est facile. Avec une participation par bouton, tu valides chaque clic instantanément, gardes les participants dans une source unique (la base) et bloques définitivement les participations en double.
Qu'advient-il des tirages en cours si le bot tombe en panne ?
Tant que tu persistes la valeur endsAt, il n'y a aucun problème. Quand le bot est ready, tu lis les tirages actifs et reprogrammes selon le temps restant ; ceux déjà expirés sont clôturés dès le redémarrage. Si tu gardes tout l'état uniquement en mémoire, un redémarrage efface tout.
Comment laisser participer uniquement les membres ayant un certain rôle ?
Dans le handler du bouton de participation, ajoute une vérification interaction.member.roles.cache.has(roleId) et, en cas d'échec, refuse la participation avec un avertissement éphémère. La même approche permet d'imposer des conditions comme un âge de compte minimum ou une ancienneté sur le serveur.
Tu veux un bot de tirage solide pour ton serveur ? Je peux préparer une solution clé en main avec tirages minutés, conditions de rôle, nouveaux tirages et base de données persistante. Contacte-moi et discutons de tes besoins.