Each example below was run against a test copy of Discord: entering, leaving and entering again, the bot restarting halfway through, the winner being picked and announced, and a giveaway nobody entered.
How it works
/giveawayposts a message with an Enter button and the end time, which Discord shows in each reader's own time zone.- Every press is saved to
giveaways.jsonstraight away. Pressing again takes the entry back. - A timer waits for the end. When the bot starts, it reads the file and sets the timers again, so a restart forgets nothing, and a giveaway that ended while the bot was down ends as soon as it is back.
- At the end, the message is edited to show the winner, the button goes, and the winner is congratulated.
No privileged intent is needed. The bot needs Send Messages in the channel; members need Manage Server to start one, which you can change under Server Settings, Integrations.
discord.py
import asyncio
import json
import os
import pathlib
import random
import re
import time
import discord
from discord import app_commands
from discord.ext import commands
SAVED = pathlib.Path(__file__).with_name("giveaways.json") # so a restart forgets nothing
UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
running: set[asyncio.Task] = set()
def load() -> dict:
try:
return json.loads(SAVED.read_text())
except (OSError, ValueError):
return {}
giveaways = load() # message ID -> {"channel", "prize", "ends", "entries"}
def save() -> None:
tmp = SAVED.with_suffix(".tmp")
tmp.write_text(json.dumps(giveaways))
tmp.replace(SAVED) # the whole file or nothing, even if the bot stops halfway
def seconds(text: str) -> int | None:
m = re.fullmatch(r"(\d+)\s*([smhd])", text.strip().lower())
return int(m[1]) * UNITS[m[2]] if m else None
class Enter(discord.ui.View):
def __init__(self):
super().__init__(timeout=None)
@discord.ui.button(label="Enter", style=discord.ButtonStyle.success, custom_id="giveaway:enter")
async def enter(self, interaction: discord.Interaction, button: discord.ui.Button):
g = giveaways.get(str(interaction.message.id))
if g is None:
return await interaction.response.send_message("This giveaway has ended.", ephemeral=True)
if interaction.user.id in g["entries"]:
g["entries"].remove(interaction.user.id)
said = "You left the giveaway."
else:
g["entries"].append(interaction.user.id)
said = "You are in. Good luck!"
save()
await interaction.response.send_message(f"{said} {len(g['entries'])} entered so far.", ephemeral=True)
async def finish(message_id: str):
await bot.wait_until_ready()
await asyncio.sleep(max(0, giveaways[message_id]["ends"] - time.time()))
g = giveaways.pop(message_id)
save()
channel = bot.get_channel(g["channel"]) or await bot.fetch_channel(g["channel"])
winner = random.choice(g["entries"]) if g["entries"] else None
result = f"Winner: <@{winner}>" if winner else "Nobody entered."
await channel.get_partial_message(int(message_id)).edit(content=f"๐ **{g['prize']}**, ended. {result}", view=None)
if winner:
await channel.send(f"Congratulations <@{winner}>, you won **{g['prize']}**!")
def start(message_id: str) -> None:
task = asyncio.create_task(finish(message_id))
running.add(task) # asyncio keeps only a weak reference to a task
task.add_done_callback(running.discard)
class MyBot(commands.Bot):
async def setup_hook(self):
self.add_view(Enter()) # the Enter buttons keep working after a restart
for message_id in list(giveaways):
start(message_id) # and the giveaways still end on time
await self.tree.sync()
bot = MyBot(command_prefix="!", intents=discord.Intents.default())
@bot.tree.command(description="Start a giveaway in this channel")
@app_commands.describe(prize="What the winner gets", duration="How long: 30m, 2h or 3d, up to 30 days")
@app_commands.default_permissions(manage_guild=True)
@app_commands.guild_only()
async def giveaway(interaction: discord.Interaction, prize: app_commands.Range[str, 1, 200], duration: str):
length = seconds(duration)
if length is None or not 10 <= length <= 30 * 86400:
return await interaction.response.send_message("Give a duration like 30m, 2h or 3d, up to 30 days.", ephemeral=True)
ends = int(time.time()) + length
await interaction.response.send_message(f"๐ **{prize}**\nPress Enter to take part. Ends <t:{ends}:R>.", view=Enter())
message = await interaction.original_response()
giveaways[str(message.id)] = {"channel": message.channel.id, "prize": prize, "ends": ends, "entries": []}
save()
start(str(message.id))
bot.run(os.environ["BOT_TOKEN"])- The file is written whole or not at all.
save()writes a temporary file and then swaps it in, so a bot stopped halfway through a write never leaves half a file behind. runningholds on to each waiting task. asyncio only keeps a weak reference to a task, so one nothing else refers to can disappear before it finishes.add_viewinsetup_hookkeeps the Enter buttons answering after a restart; the view has no timeout and a fixed custom ID.
discord.js
const fs = require("node:fs");
const path = require("node:path");
const {
ActionRowBuilder, ButtonBuilder, ButtonStyle, Client, Events, GatewayIntentBits, MessageFlags,
} = require("discord.js");
const SAVED = path.join(__dirname, "giveaways.json"); // so a restart forgets nothing
const UNITS = { s: 1, m: 60, h: 3600, d: 86400 };
const DAY_MS = 24 * 60 * 60 * 1000;
let giveaways = {}; // message ID -> { channel, prize, ends, entries }
try {
giveaways = JSON.parse(fs.readFileSync(SAVED, "utf8"));
} catch {
// no file yet
}
function save() {
fs.writeFileSync(SAVED + ".tmp", JSON.stringify(giveaways));
fs.renameSync(SAVED + ".tmp", SAVED); // the whole file or nothing, even if the bot stops halfway
}
function seconds(text) {
const m = /^(\d+)\s*([smhd])$/.exec(text.trim().toLowerCase());
return m ? Number(m[1]) * UNITS[m[2]] : null;
}
const enterRow = new ActionRowBuilder().addComponents(
new ButtonBuilder().setCustomId("giveaway:enter").setLabel("Enter").setStyle(ButtonStyle.Success),
);
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// setTimeout cannot wait longer than about 24.8 days (a longer wait fires at once),
// so this waits a day at most and looks again.
function schedule(messageId) {
const left = giveaways[messageId].ends * 1000 - Date.now();
if (left > 0) return setTimeout(() => schedule(messageId), Math.min(left, DAY_MS));
finish(messageId).catch(console.error);
}
async function finish(messageId) {
const g = giveaways[messageId];
delete giveaways[messageId];
save();
const channel = await client.channels.fetch(g.channel);
const winner = g.entries.length ? g.entries[Math.floor(Math.random() * g.entries.length)] : null;
const result = winner ? `Winner: <@${winner}>` : "Nobody entered.";
await channel.messages.edit(messageId, { content: `๐ **${g.prize}**, ended. ${result}`, components: [] });
if (winner) await channel.send(`Congratulations <@${winner}>, you won **${g.prize}**!`);
}
client.once(Events.ClientReady, () => {
for (const messageId of Object.keys(giveaways)) schedule(messageId); // still end on time after a restart
});
client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.isChatInputCommand() && interaction.commandName === "giveaway") {
const prize = interaction.options.getString("prize");
const length = seconds(interaction.options.getString("duration"));
if (length === null || length < 10 || length > 30 * 86400) {
return interaction.reply({ content: "Give a duration like 30m, 2h or 3d, up to 30 days.", flags: MessageFlags.Ephemeral });
}
const ends = Math.floor(Date.now() / 1000) + length;
const response = await interaction.reply({
content: `๐ **${prize}**\nPress Enter to take part. Ends <t:${ends}:R>.`,
components: [enterRow],
withResponse: true,
});
const message = response.resource.message;
giveaways[message.id] = { channel: message.channelId, prize, ends, entries: [] };
save();
schedule(message.id);
} else if (interaction.isButton() && interaction.customId === "giveaway:enter") {
const g = giveaways[interaction.message.id];
if (!g) return interaction.reply({ content: "This giveaway has ended.", flags: MessageFlags.Ephemeral });
const at = g.entries.indexOf(interaction.user.id);
if (at === -1) g.entries.push(interaction.user.id);
else g.entries.splice(at, 1);
save();
const said = at === -1 ? "You are in. Good luck!" : "You left the giveaway.";
await interaction.reply({ content: `${said} ${g.entries.length} entered so far.`, flags: MessageFlags.Ephemeral });
}
});
client.login(process.env.BOT_TOKEN);And the command, registered with your deploy script (the slash commands guide has one):
// in deploy-commands.js, next to your other commands
const { InteractionContextType, PermissionFlagsBits, SlashCommandBuilder } = require("discord.js");
new SlashCommandBuilder()
.setName("giveaway")
.setDescription("Start a giveaway in this channel")
.addStringOption((o) => o.setName("prize").setDescription("What the winner gets").setRequired(true).setMaxLength(200))
.addStringOption((o) => o.setName("duration").setDescription("How long: 30m, 2h or 3d, up to 30 days").setRequired(true))
.setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
.setContexts(InteractionContextType.Guild);- The 24.8-day limit.
setTimeoutcannot wait longer than 2,147,483,647 ms, about 24.8 days. Ask for longer and Node prints aTimeoutOverflowWarningand runs it after 1 ms, which here would end a month-long giveaway the moment it starts.schedulewaits a day at most, looks again, and only finishes when the time has really come. withResponse: truereturns the posted message, whose ID is the giveaway's key.- Saved giveaways are scheduled in
ClientReady, once the bot can fetch their channels.
Before you rely on it
- The file has to survive too. Some free hosts wipe the disk on every restart, and the giveaways go with it. Keep the file on storage that lasts, or use a database.
- Rerolls. This version forgets the entries once a winner is picked. To offer a reroll, keep finished giveaways in the file with a flag, and pick again from the same entries.
- Who can enter. Anyone who can see the channel. To require a role, check
interaction.user.roles(discord.py) orinteraction.member.roles.cache(discord.js) in the Enter handler.
Common problems
- Giveaways never end after a restart: the timers are only set when a giveaway starts. Set them again on startup from the saved file, as both examples do.
- A long giveaway ends at once (discord.js): a single
setTimeoutlonger than 24.8 days. Wait in steps. - "This interaction failed" on Enter: the bot was not running, or (discord.py) the view was not added in
setup_hook. - "This giveaway has ended" on a running one: the file was lost, so the bot no longer knows about it.
Did this page help?
Thanks for the feedback.