Guide

Scheduled tasks in a Discord bot

Daily reminders, hourly stat updates, a weekly leaderboard reset. The code is short; the parts that go wrong are starting it before the bot is ready, time zones, and what happens when the bot restarts.

7 minute read

discord.py: tasks.loop

discord.ext.tasks runs a coroutine on a schedule and handles the waiting and reconnects for you.

import datetime
import os

import discord
from discord.ext import commands, tasks

CHANNEL_ID = 123456789012345678
MIDNIGHT_UTC = datetime.time(hour=0, minute=0, tzinfo=datetime.timezone.utc)

class Reminders(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.daily.start()

    def cog_unload(self):
        self.daily.cancel()

    @tasks.loop(time=MIDNIGHT_UTC)
    async def daily(self):
        channel = self.bot.get_channel(CHANNEL_ID)
        if channel:
            await channel.send("A new day. Drink some water.")

    @daily.before_loop
    async def before_daily(self):
        await self.bot.wait_until_ready()

async def setup(bot: commands.Bot):
    await bot.add_cog(Reminders(bot))
  • @tasks.loop(minutes=10) runs every ten minutes from when it starts. @tasks.loop(time=...) runs at fixed times of day, and takes a list for several: time=[datetime.time(9), datetime.time(21)].
  • Always give the time a time zone. A datetime.time without tzinfo is treated as UTC, which is fine if you meant UTC and an hour or two out if you did not. Servers usually run on UTC or a data-centre time zone, not yours.
  • before_loop with wait_until_ready() stops the first run happening before the bot has logged in, when get_channel still returns None.
  • An exception inside the loop stops the loop silently unless you handle it. Wrap the body in try/except and log, or add an @daily.error handler.

discord.js: setInterval, or node-cron for clock times

For "every N minutes", plain setInterval is enough. Start it once the client is ready:

const { Client, Events, GatewayIntentBits } = require("discord.js");

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const CHANNEL_ID = "123456789012345678";

client.once(Events.ClientReady, () => {
  setInterval(async () => {
    try {
      const channel = await client.channels.fetch(CHANNEL_ID);
      await channel.send("Hourly check-in.");
    } catch (err) {
      console.error("hourly task failed:", err);
    }
  }, 60 * 60 * 1000);
});

client.login(process.env.BOT_TOKEN);

For "every day at 09:00", use node-cron (npm install node-cron) and give it the time zone:

const cron = require("node-cron");

client.once(Events.ClientReady, () => {
  cron.schedule("0 9 * * *", async () => {
    const channel = await client.channels.fetch(CHANNEL_ID);
    await channel.send("Good morning!");
  }, { timezone: "Europe/London" });
});

Using client.once rather than client.on matters: the ready event can fire again after a reconnect, and on would start a second timer each time.

Restarts: the schedule lives in memory

A timer starts counting when the bot starts. Restart it at 10:59 and an hourly task next runs at 11:59, not 11:00. Two consequences:

  • Use clock times for things people expect at a time. tasks.loop(time=...) and cron schedules fire at the time regardless of when the bot started.
  • A run missed while the bot was down is simply missed. If that matters (a weekly reset, a reminder someone set), store the next due time in a file or database and, on start, run anything that is overdue.
# the pattern, in Python
last_reset = load_from_db("last_weekly_reset")
if now - last_reset > datetime.timedelta(days=7):
    await do_weekly_reset()
    save_to_db("last_weekly_reset", now)

User reminders ("remind me in 3 days") should always work this way: save the time, check every minute for anything due. A three-day sleep is lost the moment the bot restarts.

Do not hammer Discord

  • Editing a message or renaming a channel every few seconds to show a live counter gets rate limited quickly; channel renames are limited to two every ten minutes. Update every few minutes instead. How rate limits work.
  • A loop that sends to hundreds of channels at once should pace itself. discord.py and discord.js both queue requests for you, but a job that sends 500 messages still takes as long as the limits say.