A giveaway bot posts a prize, collects entrants through a button, waits out a timer, and picks a random winner. The tricky part isn't the button — it's the timer. If your bot restarts while a giveaway is running (a redeploy, a session timer expiring), a giveaway kept only in memory vanishes. This recipe stores giveaways in SQLite so they survive restarts: on boot, the bot re-arms every giveaway that was still running.
| At a glance | |
|---|---|
| You need | A working bot from Host a discord.js bot |
| Plan | Free or premium — no premium features required |
| Time | About thirty minutes |
New here? Build the base bot with Host a discord.js bot first.
Add the package
This recipe uses better-sqlite3 — a real database in a single file, with no separate server to run. It ships prebuilt binaries, so it installs cleanly on the Node.js application. Open the Packages page in your server menu, install better-sqlite3, and restart. (More on the trade-offs in Storing data for your bot.)
The whole bot
Here's the complete index.js. /giveaway starts one; an Enter button collects entrants; a timer ends it and announces a winner.
require('dotenv').config();
const Database = require('better-sqlite3');
const {
Client, GatewayIntentBits, Events,
ActionRowBuilder, ButtonBuilder, ButtonStyle, EmbedBuilder, SlashCommandBuilder,
} = require('discord.js');
const db = new Database('giveaways.db');
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS giveaways (
message_id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL,
prize TEXT NOT NULL,
ends_at INTEGER NOT NULL,
ended INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS entries (
message_id TEXT NOT NULL,
user_id TEXT NOT NULL,
PRIMARY KEY (message_id, user_id)
);
`);
const addGiveaway = db.prepare('INSERT INTO giveaways (message_id, channel_id, prize, ends_at) VALUES (?, ?, ?, ?)');
const addEntry = db.prepare('INSERT OR IGNORE INTO entries (message_id, user_id) VALUES (?, ?)');
const getEntrants = db.prepare('SELECT user_id FROM entries WHERE message_id = ?');
const getGiveaway = db.prepare('SELECT * FROM giveaways WHERE message_id = ?');
const markEnded = db.prepare('UPDATE giveaways SET ended = 1 WHERE message_id = ?');
const openGiveaways = db.prepare('SELECT * FROM giveaways WHERE ended = 0');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
function parseDuration(text) {
const m = /^(\d+)(s|m|h|d)$/.exec(text.trim());
if (!m) return null;
return Number(m[1]) * { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2]];
}
async function endGiveaway(messageId) {
const row = getGiveaway.get(messageId);
if (!row || row.ended) return;
markEnded.run(messageId);
const channel = await client.channels.fetch(row.channel_id).catch(() => null);
const entrants = getEntrants.all(messageId).map((e) => e.user_id);
const winner = entrants.length ? entrants[Math.floor(Math.random() * entrants.length)] : null;
if (channel) {
await channel.send(
winner
? `🎉 The giveaway for **${row.prize}** is over — congratulations <@${winner}>!`
: `The giveaway for **${row.prize}** ended with no entries.`,
);
}
}
function schedule(messageId, endsAt) {
setTimeout(() => endGiveaway(messageId), Math.max(0, endsAt - Date.now()));
}
client.once(Events.ClientReady, async (c) => {
await c.application.commands.set([
new SlashCommandBuilder()
.setName('giveaway')
.setDescription('Start a giveaway')
.addStringOption((o) => o.setName('prize').setDescription('What to give away').setRequired(true))
.addStringOption((o) => o.setName('duration').setDescription('e.g. 30m, 2h, 1d').setRequired(true))
.toJSON(),
]);
// Re-arm every giveaway that was still running when the bot last stopped.
for (const row of openGiveaways.all()) schedule(row.message_id, row.ends_at);
console.log(`Listening as ${c.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isChatInputCommand() && interaction.commandName === 'giveaway') {
const prize = interaction.options.getString('prize');
const ms = parseDuration(interaction.options.getString('duration'));
if (!ms) {
await interaction.reply({ content: 'Duration must look like `30m`, `2h`, or `1d`.', ephemeral: true });
return;
}
const endsAt = Date.now() + ms;
const embed = new EmbedBuilder()
.setTitle('🎉 Giveaway!')
.setDescription(`Prize: **${prize}**\nEnds <t:${Math.floor(endsAt / 1000)}:R>\nClick the button to enter.`)
.setColor(0x5865f2);
const row = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId('giveaway:enter').setLabel('Enter').setStyle(ButtonStyle.Success).setEmoji('🎉'),
);
const message = await interaction.reply({ embeds: [embed], components: [row], fetchReply: true });
addGiveaway.run(message.id, message.channelId, prize, endsAt);
schedule(message.id, endsAt);
return;
}
if (interaction.isButton() && interaction.customId === 'giveaway:enter') {
addEntry.run(interaction.message.id, interaction.user.id);
await interaction.reply({ content: 'You are entered. Good luck! 🍀', ephemeral: true });
}
});
client.login(process.env.DISCORD_TOKEN);
Restart and run /giveaway prize:"Steam key" duration:1m.
How it works
- Two tables.
giveawaysholds each prize and its end time;entriesholds one row per (giveaway, user). ThePRIMARY KEY (message_id, user_id)plusINSERT OR IGNOREmeans clicking Enter twice counts once — no double entries. - The message ID is the key. The button's giveaway is identified by the message it sits on, so there's nothing to look up but
interaction.message.id. - Restarts are survivable. Every giveaway is written to disk the moment it starts. On
ClientReady, the bot reads every giveaway whereended = 0and callsscheduleagain. If one was already past due while the bot was down, the timer delay clamps to0and it ends immediately. <t:…:R>renders a live "ends in 3 minutes" countdown that Discord updates for every viewer, in their own timezone.
💡 Tip: Picking the winner is one line: a random index into the entrants array. To add a reroll, keep the giveaway row after it ends and add a
/rerollcommand that reads the sameentriesand picks again.
⚠️ Heads up:
setTimeoutcan only wait about 24.8 days. For giveaways longer than that, don't schedule the whole span at once — run a small check every few minutes that ends any giveaway whoseends_athas passed. A scheduled task is a clean way to do the sweep.
Where the data lives
giveaways.db sits in /home/container, on the server. It survives restarts — that's the whole point — but a reinstall or an application switch wipes it, like any file. For giveaways that must outlive that, use a managed database instead. Add giveaways.db to your .gitignore so it never lands in a repo.
Verify it works
Run a one-minute giveaway, click Enter from a couple of accounts, and wait — the bot should announce a winner. Now the real test: start a giveaway, restart the server before it ends, and confirm the bot still announces a winner when the timer runs out. That proves the SQLite persistence is doing its job.
Troubleshooting
| Symptom | Fix |
|---|---|
Cannot find module 'better-sqlite3' |
Install it from the Packages page, then restart. |
| Giveaway never ends after a restart | Confirm the re-arm loop runs on ClientReady and that giveaways.db wasn't deleted (a reinstall wipes it). |
| Same person enters many times | They can't — the PRIMARY KEY (message_id, user_id) blocks duplicates. If you removed it, add it back. |
| "This interaction failed" | An error was thrown in the handler before replying. Check the console; wrap risky work in try/catch — see Crash-proof your bot. |