NapNodes

← Discord bot guides

Discord bots · updated 2 November 2026

Discord scheduled messages bot: announcements and reminders on time (Python and JavaScript)

The daily “good morning!”, the Friday event announcement or the reminder to vote: repeated messages are the first ones to be forgotten. With this bot, whoever manages the server schedules them once from Discord (channel, time, text and which days) and the bot posts them on its own, without repeating them even after a restart. Full code in discord.py and discord.js, no database, no date libraries and no privileged intents.

What the bot does

Three commands: /schedule to create a message (channel, time, text and which days), /scheduled to see the existing ones with their number and /unschedule to delete one. By default only people who manage the server see them: nobody else can flood a channel with announcements.

It's for anything that repeats: good mornings, the weekly event announcement, the reminder to vote for the server, the “restart at 6” of your game server or the question of the day. Each message goes out on time every day, Monday to Friday or only at the weekend.

It saves which day each message was already sent, so a restart doesn't repeat it. If the bot was off at that time, it sends it when it's back, but only if no more than 2 hours have passed: a “good morning” at midnight helps nobody. Everything goes in scheduled.json, next to the bot, and one bot handles the messages of several servers at once (up to 15 on each).

Rather not set it up yourself? Discord bot hosting from €1.50 a month, no contract and a full refund within 72 hours. Or create a free Discord bot, no card needed.

1. Create the bot and invite it

  1. Create the application and get the token

    At discord.com/developers/applications, “New Application”; in the “Bot” tab, “Reset Token” and copy the token. It's the bot's password: don't share it or push it to GitHub. If it's your first bot, the full walkthrough is in your first bot in Python or in JavaScript. “Privileged Gateway Intents” stay off.

  2. Invite it with two permissions

    In “OAuth2” → “URL Generator”, tick “bot” and “applications.commands”, and in the permissions “View Channels” and “Send Messages” (in total, the number 3072). The bot permissions calculator shows them and gives you the link.

2. The bot in Python (discord.py)

Three files in a folder. The token goes in .env, without quotes. You need Python 3.10 or newer. tzdata brings the time zones in case the system the bot runs on doesn't have them. Times are in ZONE; for another country, change it (for example, America/Mexico_City or Atlantic/Canary).

requirements.txt
discord.py
python-dotenv
tzdata
.env
DISCORD_TOKEN=paste-your-token-here
app.py
import json
import os
import re
from datetime import datetime
from pathlib import Path
from typing import Optional
from zoneinfo import ZoneInfo

import discord
from discord import app_commands
from discord.ext import tasks
from dotenv import load_dotenv

load_dotenv()  # reads the token from the .env file

# Messages are saved next to the bot: a restart doesn't lose them.
DATA = Path("scheduled.json")
ZONE = ZoneInfo("Europe/London")  # times are in this zone, not the computer's
MAX_DELAY = 120  # if it was off at that time, it sends it when back, but at most 2 hours late
MAX_PER_SERVER = 15  # scheduled messages per server
DAYS = {"every": "every day", "weekdays": "Monday to Friday", "weekend": "Saturdays and Sundays"}
DUE = {"every": range(7), "weekdays": range(5), "weekend": (5, 6)}  # 0 is Monday
VALID_TIME = re.compile(r"^([01]?\d|2[0-3]):([0-5]\d)$")
MENTIONS = discord.AllowedMentions(everyone=False, users=True, roles=True)  # never @everyone


def load() -> dict:
    return json.loads(DATA.read_text("utf-8")) if DATA.exists() else {}


def save(data: dict) -> None:
    DATA.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")


def minutes(hhmm: str) -> int:
    h, m = hhmm.split(":")
    return int(h) * 60 + int(m)


class Bot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())  # no privileged intents
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        await self.tree.sync()  # publishes the commands
        send_due.start()


bot = Bot()


@tasks.loop(minutes=1)  # checks every minute whether a message is due
async def send_due():
    now = datetime.now(ZONE)
    today = now.date().isoformat()
    current = now.hour * 60 + now.minute
    for guild_id, messages in load().items():
        for m in messages:
            late = current - minutes(m["time"])
            if m["last"] == today or not 0 <= late <= MAX_DELAY or now.weekday() not in DUE[m["days"]]:
                continue
            channel = bot.get_channel(m["channel"])
            if channel is None:
                continue  # channel deleted, or the bot is no longer on that server
            try:
                await channel.send(m["text"], allowed_mentions=MENTIONS)
            except discord.HTTPException as error:
                print(f"Couldn't send #{m['id']} on {guild_id}: {error!r}")
                continue  # it tries again on the next round
            fresh = load()  # read again in case someone used a command meanwhile
            for f in fresh.get(guild_id, []):
                if f["id"] == m["id"]:
                    f["last"] = today
            save(fresh)


@send_due.before_loop
async def before_send_due():
    await bot.wait_until_ready()


@bot.tree.command(name="schedule", description="Schedule a message the bot posts at a set time")
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)  # by default, only people who manage the server
@app_commands.describe(channel="Where it's posted", time="Time like 09:00 or 21:30, in the bot's zone",
                       text="The message; type \\n for a new line", days="Which days (every day by default)")
@app_commands.choices(days=[app_commands.Choice(name=label, value=key) for key, label in DAYS.items()])
async def schedule(interaction: discord.Interaction, channel: discord.TextChannel, time: str,
                   text: app_commands.Range[str, 1, 1800], days: Optional[app_commands.Choice[str]] = None):
    valid = VALID_TIME.match(time.strip())
    if not valid:
        await interaction.response.send_message("Write the time like this: 09:00 or 21:30.", ephemeral=True)
        return
    time = f"{int(valid[1]):02d}:{valid[2]}"
    if not channel.permissions_for(interaction.guild.me).send_messages:
        await interaction.response.send_message(f"I can't write in {channel.mention}: give me “View Channel” and “Send Messages” there.", ephemeral=True)
        return
    data = load()
    messages = data.setdefault(str(interaction.guild_id), [])
    if len(messages) >= MAX_PER_SERVER:
        await interaction.response.send_message(f"There are {MAX_PER_SERVER} scheduled messages already: remove one with /unschedule.", ephemeral=True)
        return
    now = datetime.now(ZONE)
    # If that time has already passed today, it starts tomorrow (otherwise it would go out right now).
    passed = now.hour * 60 + now.minute >= minutes(time)
    new = {"id": max((m["id"] for m in messages), default=0) + 1, "channel": channel.id, "time": time,
           "days": days.value if days else "every", "text": text.replace("\\n", "\n"),
           "last": now.date().isoformat() if passed else None}
    messages.append(new)
    save(data)
    await interaction.response.send_message(
        f"Scheduled (#{new['id']}): {DAYS[new['days']]} at {time} in {channel.mention}.", ephemeral=True)


@bot.tree.command(name="scheduled", description="This server's scheduled messages")
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
async def scheduled(interaction: discord.Interaction):
    messages = sorted(load().get(str(interaction.guild_id), []), key=lambda m: m["time"])
    if not messages:
        await interaction.response.send_message("There aren't any. Create one with /schedule.", ephemeral=True)
        return
    lines = []
    for m in messages:
        summary = m["text"].replace("\n", " ")
        if len(summary) > 60:
            summary = summary[:60] + "…"
        lines.append(f"**{m['id']}** · {m['time']}, {DAYS[m['days']]}, in <#{m['channel']}>: {summary}")
    await interaction.response.send_message("\n".join(lines), ephemeral=True,
                                            allowed_mentions=discord.AllowedMentions.none())


@bot.tree.command(name="unschedule", description="Remove a scheduled message")
@app_commands.guild_only()
@app_commands.default_permissions(manage_guild=True)
@app_commands.describe(number="The number shown in /scheduled")
async def unschedule(interaction: discord.Interaction, number: int):
    data = load()
    messages = data.get(str(interaction.guild_id), [])
    left = [m for m in messages if m["id"] != number]
    if len(left) == len(messages):
        await interaction.response.send_message(f"There's no message number {number}. See them with /scheduled.", ephemeral=True)
        return
    data[str(interaction.guild_id)] = left
    save(data)
    await interaction.response.send_message(f"Removed #{number}: it won't be posted any more.", ephemeral=True)


@bot.event
async def on_ready():
    print(f"Logged in as {bot.user}")


bot.run(os.environ["DISCORD_TOKEN"])

The same bot in JavaScript (discord.js)

package.json, .env and index.js. You need Node.js 20 or newer. The time and day of the week in the chosen zone come from Intl.DateTimeFormat, which ships with Node: no need for node-cron or any date library.

package.json
{
  "name": "scheduled-messages-bot",
  "version": "1.0.0",
  "main": "index.js",
  "dependencies": {
    "discord.js": "^14.16.0",
    "dotenv": "^16.4.0"
  }
}
.env
DISCORD_TOKEN=paste-your-token-here
index.js
require("dotenv").config(); // reads the token from the .env file
const fs = require("node:fs");
const path = require("node:path");
const { ChannelType, Client, Events, GatewayIntentBits, InteractionContextType, MessageFlags, PermissionFlagsBits, SlashCommandBuilder } = require("discord.js");

// Messages are saved next to the bot: a restart doesn't lose them.
const DATA = path.join(__dirname, "scheduled.json");
const ZONE = "Europe/London"; // times are in this zone, not the computer's
const MAX_DELAY = 120; // if it was off at that time, it sends it when back, but at most 2 hours late
const MAX_PER_SERVER = 15; // scheduled messages per server
const DAYS = { every: "every day", weekdays: "Monday to Friday", weekend: "Saturdays and Sundays" };
const DUE = { every: [0, 1, 2, 3, 4, 5, 6], weekdays: [1, 2, 3, 4, 5], weekend: [0, 6] }; // 0 is Sunday
const VALID_TIME = /^([01]?\d|2[0-3]):([0-5]\d)$/;
const MENTIONS = { parse: ["users", "roles"] }; // never @everyone

const load = () => (fs.existsSync(DATA) ? JSON.parse(fs.readFileSync(DATA, "utf8")) : {});
const save = (data) => fs.writeFileSync(DATA, JSON.stringify(data, null, 2));
const minutes = (hhmm) => Number(hhmm.slice(0, 2)) * 60 + Number(hhmm.slice(3));

// The date (“YYYY-MM-DD”), minutes since midnight and day of the week, in the chosen zone.
function now() {
  const parts = new Intl.DateTimeFormat("en-US", { timeZone: ZONE, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", weekday: "short", hourCycle: "h23" }).formatToParts(new Date());
  const p = Object.fromEntries(parts.map((x) => [x.type, x.value]));
  const weekday = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].indexOf(p.weekday);
  return { today: `${p.year}-${p.month}-${p.day}`, current: Number(p.hour) * 60 + Number(p.minute), weekday };
}

const client = new Client({ intents: [GatewayIntentBits.Guilds] }); // no privileged intents

async function sendDue() {
  const { today, current, weekday } = now();
  for (const [guildId, messages] of Object.entries(load())) {
    for (const m of messages) {
      const late = current - minutes(m.time);
      if (m.last === today || late < 0 || late > MAX_DELAY || !DUE[m.days].includes(weekday)) continue;
      const channel = client.channels.cache.get(m.channel);
      if (!channel) continue; // channel deleted, or the bot is no longer on that server
      try {
        await channel.send({ content: m.text, allowedMentions: MENTIONS });
      } catch (error) {
        console.error(`Couldn't send #${m.id} on ${guildId}:`, error);
        continue; // it tries again on the next round
      }
      const fresh = load(); // read again in case someone used a command meanwhile
      for (const f of fresh[guildId] ?? []) if (f.id === m.id) f.last = today;
      save(fresh);
    }
  }
}

const commands = [
  new SlashCommandBuilder().setName("schedule").setDescription("Schedule a message the bot posts at a set time")
    .setContexts(InteractionContextType.Guild)
    .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) // by default, only people who manage the server
    .addChannelOption((o) => o.setName("channel").setDescription("Where it's posted").setRequired(true).addChannelTypes(ChannelType.GuildText))
    .addStringOption((o) => o.setName("time").setDescription("Time like 09:00 or 21:30, in the bot's zone").setRequired(true))
    .addStringOption((o) => o.setName("text").setDescription("The message; type \\n for a new line").setRequired(true).setMaxLength(1800))
    .addStringOption((o) => o.setName("days").setDescription("Which days (every day by default)").addChoices(...Object.entries(DAYS).map(([value, name]) => ({ name, value })))),
  new SlashCommandBuilder().setName("scheduled").setDescription("This server's scheduled messages")
    .setContexts(InteractionContextType.Guild).setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild),
  new SlashCommandBuilder().setName("unschedule").setDescription("Remove a scheduled message")
    .setContexts(InteractionContextType.Guild).setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild)
    .addIntegerOption((o) => o.setName("number").setDescription("The number shown in /scheduled").setRequired(true)),
];

client.on(Events.InteractionCreate, async (i) => {
  if (!i.isChatInputCommand()) return;
  const ephemeral = { flags: MessageFlags.Ephemeral };
  const data = load();
  const messages = (data[i.guildId] ??= []);

  if (i.commandName === "schedule") {
    const channel = i.options.getChannel("channel");
    const valid = VALID_TIME.exec(i.options.getString("time").trim());
    if (!valid) {
      await i.reply({ content: "Write the time like this: 09:00 or 21:30.", ...ephemeral });
      return;
    }
    const time = `${valid[1].padStart(2, "0")}:${valid[2]}`;
    if (!channel.permissionsFor(i.guild.members.me).has(PermissionFlagsBits.SendMessages)) {
      await i.reply({ content: `I can't write in ${channel}: give me “View Channel” and “Send Messages” there.`, ...ephemeral });
      return;
    }
    if (messages.length >= MAX_PER_SERVER) {
      await i.reply({ content: `There are ${MAX_PER_SERVER} scheduled messages already: remove one with /unschedule.`, ...ephemeral });
      return;
    }
    const { today, current } = now();
    const days = i.options.getString("days") ?? "every";
    const created = {
      id: Math.max(0, ...messages.map((m) => m.id)) + 1, channel: channel.id, time, days,
      text: i.options.getString("text").replaceAll("\\n", "\n"),
      last: current >= minutes(time) ? today : null, // if that time has already passed today, it starts tomorrow
    };
    messages.push(created);
    save(data);
    await i.reply({ content: `Scheduled (#${created.id}): ${DAYS[days]} at ${time} in ${channel}.`, ...ephemeral });
  } else if (i.commandName === "scheduled") {
    if (!messages.length) {
      await i.reply({ content: "There aren't any. Create one with /schedule.", ...ephemeral });
      return;
    }
    const lines = [...messages].sort((a, b) => a.time.localeCompare(b.time)).map((m) => {
      const summary = m.text.replaceAll("\n", " ");
      return `**${m.id}** · ${m.time}, ${DAYS[m.days]}, in <#${m.channel}>: ${summary.length > 60 ? summary.slice(0, 60) + "…" : summary}`;
    });
    await i.reply({ content: lines.join("\n"), allowedMentions: { parse: [] }, ...ephemeral });
  } else if (i.commandName === "unschedule") {
    const number = i.options.getInteger("number");
    const left = messages.filter((m) => m.id !== number);
    if (left.length === messages.length) {
      await i.reply({ content: `There's no message number ${number}. See them with /scheduled.`, ...ephemeral });
      return;
    }
    data[i.guildId] = left;
    save(data);
    await i.reply({ content: `Removed #${number}: it won't be posted any more.`, ...ephemeral });
  }
});

client.once(Events.ClientReady, async (c) => {
  await c.application.commands.set(commands.map((x) => x.toJSON())); // publishes the commands
  console.log(`Logged in as ${c.user.tag}`);
  setInterval(() => sendDue().catch(console.error), 60 * 1000); // checks every minute whether a message is due
  sendDue().catch(console.error);
});

client.login(process.env.DISCORD_TOKEN);

3. Test it

Who sees the three commands can be changed without touching the code in “Server Settings” → “Integrations” → your bot: for example, so moderators can use them too.

  1. Start the bot

    In a terminal, in the folder: pip install -r requirements.txt and python app.py (or npm install and node index.js). When it says “Logged in as…”, you're done. The commands can take a few minutes to show up the first time.

  2. Schedule one for two minutes from now

    Type /schedule, pick the channel, set a time two or three minutes later than now in your zone and a text like It works! \n Second line. As soon as that time comes, a minute later at most, it shows up in the channel. A time that has already passed today starts tomorrow.

  3. See it and remove it

    /scheduled shows the list, sorted by time, with each one's number; /unschedule with that number deletes it.

Time, days and when the bot was off

The bot doesn't use the time of the computer it runs on, but that of ZONE. So it doesn't matter whether it's at your home or on a server in another country, and it handles the clock change in March and October by itself.

It checks every minute. If a message is for 9:00 and the bot was off, it sends it as soon as it's back, until 11:00 (MAX_DELAY, in minutes). After that, it skips that day: better nothing than a reminder for something that has already happened. If you never want it to go out late, lower MAX_DELAY to 5.

“Monday to Friday” and “Saturdays and Sundays” use the day of the week in ZONE. For another combination (only Fridays, for example), add a line to DAYS and DUE with its numbers: in Python 0 is Monday; in JavaScript, Sunday.

Why not tasks.loop(hours=24) or a 24-hour setInterval? Because they count from when the bot starts: if you restart it at 17:00, the message moves to 17:00. For a fixed time you have to look at the time of day, as this bot does.

Line breaks, mentions and times for everyone

Slash commands don't let you type line breaks, so the bot turns \n into one: Good morning \n How's it going? comes out on two lines. Bold, links and emojis are written as in any message.

It can mention people and roles, but never @everyone or @here: that's what annoys people most and what makes them mute a server soonest. For a role, write <@&ID> with the role's ID (with Discord's developer mode on, right-click the role → “Copy Role ID”). A role only notifies if it's mentionable (in “Server Settings” → “Roles”) or if the bot also has “Mention Everyone”.

If your community is in several countries, put the event time as a Discord timestamp in short time format (<t:…:t>): everyone sees it in their own zone. Get it from the timestamp generator.

If something doesn't work

No message goes out: check the time and days with /scheduled. If you scheduled it for a time that had already passed, it starts tomorrow. If the bot lost permission to write in the channel, the console says “Missing Permissions” (code 50013) and it tries again every minute while it still has margin.

It goes out at the wrong time: set ZONE to your group's. Valid names are those of the time zone database, like Europe/Madrid, Atlantic/Canary or America/Argentina/Buenos_Aires. In Python, if it says “No time zone found”, tzdata is missing from requirements.txt.

The role mention shows but notifies nobody: the role isn't mentionable (see the section above).

The commands don't show up: what to check when slash commands don't appear. If the bot is online and doesn't answer, my bot doesn't respond.

Ideas to extend it

A fancier announcement, with colour and an image: swap the text for an embed (embeds in a Discord bot).

One-off reminders (“Saturday at 18:00, tournament”): save the date as well as the time and delete the message from scheduled.json once sent.

If you already have a welcome bot or a birthday bot, you can merge the commands into the same bot: on the same plan they use the same as one.

4. Keep it online all the time

A scheduled messages bot only works if it's on at the time: if it lives on your computer and that's off, nothing goes out. It has to be hosted on a server; free and paid options, compared, in where to host your Discord bot.

NapNodes has a free 256 MB bot that doesn't turn off when idle; you renew it every 7 days with “Start” (its catches, in this guide): if you forget to renew it, no announcements go out on those days. Create it, pick Node.js or Python in “Settings” → “Language”, upload your files in “Files” and click “Start”: the libraries in requirements.txt or package.json install themselves on startup, and scheduled.json stays in the bot's folder between restarts. So you don't have to renew it, the Mini plan (512 MB) stays online all the time (bot prices).

Frequently asked questions

How do I make a Discord bot send a message every day at the same time?

With a bot that saves the channel, time and text, and checks every minute whether a message is due. The full code in Python and JavaScript, with commands to schedule them from Discord, is in this guide.

Why does my daily message go out at a different time each day?

Because a 24-hour loop counts from when the bot started: every restart moves the time. You have to compare with the time of day in a fixed time zone, as the bot in this guide does.

What permissions does a scheduled messages bot need?

View channels and send messages (the permissions number is 3072). Only if you want to notify roles that aren't mentionable does it also need “Mention Everyone”.

Can the bot mention @everyone?

As it is, no: it can mention people and roles, but the code blocks @everyone and @here so a daily announcement doesn't end with the whole server muting it.

Where do I keep the scheduled messages bot online all the time?

On a server. NapNodes has a free 256 MB bot that you renew with one click every week, or the Mini plan, always online.

Shall we set it up for you?

Discord bot hosting from €1.50 a month, never oversold: your RAM is yours alone. And if you'd rather start without paying, there's a free Discord bot, no card needed.

Free tool: Discord bot permissions calculator. Tick your bot's permissions and get the number and the invite link; or paste a number and see which permissions it grants.

Free tool: Discord timestamp generator. Pick a date, time and time zone and copy the <t:…> so everyone on Discord sees the time in their own zone, or a countdown.

Keep reading

All Discord bot guides, sorted →