A giveaway command for a Discord bot, in discord.py and discord.js

Type /giveaway, people press Enter, and when time is up the bot picks a winner and says so. The part most examples get wrong is a restart in the middle: here the entries and the end time are kept in a file, so a giveaway still ends on time.

8 min readIntermediateUpdated Examples tested with discord.py 2.7.1 and discord.js 14.27

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

  • /giveaway posts 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.json straight 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

Python
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.
  • running holds 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_view in setup_hook keeps the Enter buttons answering after a restart; the view has no timeout and a fixed custom ID.

discord.js

JavaScript
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):

JavaScript
// 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. setTimeout cannot wait longer than 2,147,483,647 ms, about 24.8 days. Ask for longer and Node prints a TimeoutOverflowWarning and runs it after 1 ms, which here would end a month-long giveaway the moment it starts. schedule waits a day at most, looks again, and only finishes when the time has really come.
  • withResponse: true returns 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) or interaction.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 setTimeout longer 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.

Press / or Ctrl K on any page to search.