We'll write a bot in the discord.js library with two slash commands: /ping and /pozdrav (“greet”) with a parameter. We'll explain every line and finally run the bot at StormHost.
What you'll need
- Node.js LTS on your computer (download it from nodejs.org (opens in a new window)) and a code editor, for example Visual Studio Code
- An application in the Discord Developer Portal, a token and the bot invited to your test server with the
applications.commandsscope (guide) - A Discord bot at StormHost to keep it running
- About 30 minutes
How it works#
A bot with slash commands has three parts:
- Command definitions – the command's name, description and parameters. You tell Discord about them once, with a separate script over the REST API (registration).
- The running bot – connects to Discord (the gateway) and waits for events.
- Interaction handling – when someone uses a command, Discord sends the bot an interaction and the bot has to reply within 3 seconds. Otherwise the user sees that the application didn't respond.
Registration and running the bot are separate: you only need to register the commands again when you change their definition (name, description, parameters). Changes to the reply code don't need a new registration.
Setting up the project#
Create a
muj-botfolder and open a terminal in it (in VS Code, Ctrl+`).Create the project and install the libraries:
bash npm init -y npm install discord.js dotenvnpm init -y– createspackage.jsonwith default values (-y= answers “yes” to every question).npm install discord.js dotenv– installs discord.js and dotenv (loads the.envfile) and adds them todependencies.
In
package.json, set"main": "index.js"and"type": "commonjs". The examples userequire(). The fields are explained in the uploading and starting the bot guide.Create a
.envfile:.envini DISCORD_TOKEN=<BOT_TOKEN> CLIENT_ID=<APPLICATION_ID> GUILD_ID=<SERVER_ID>Replace
<BOT_TOKEN>with your bot token,<APPLICATION_ID>with the Application ID from the General Information page in the Developer Portal and<SERVER_ID>with the ID of your test Discord server. Where to find them is covered in the token and intents guide.Create a
.gitignorefile with two lines:node_modulesand.env.
The resulting structure:
muj-bot/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
├── index.js
├── deploy-commands.js
└── commands/
└── utility/
├── ping.js
└── pozdrav.jsCommands#
Each command has its own file with two parts: data (the definition for Discord) and execute (what happens when someone uses it).
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Replies with Pong! and shows the bot latency.'),
async execute(interaction) {
await interaction.reply({
content: `Pong! Latency: ${interaction.client.ws.ping} ms`,
flags: MessageFlags.Ephemeral,
});
},
};| Line | What it does |
|---|---|
require('discord.js') | Loads the SlashCommandBuilder class (builds the command definition) and MessageFlags (message flags) from the library. |
.setName('ping') | The command name the user types after the slash. 1–32 characters, only lowercase letters, digits, - and _, no spaces. |
.setDescription('…') | The description in the command menu. Required, 1–100 characters. |
async execute(interaction) | The function the bot calls when the command is used. interaction carries everything about it (who, where, parameters). |
interaction.reply({ … }) | The reply to the command. It has to arrive within 3 seconds. |
interaction. | The latency of the bot's connection to Discord in milliseconds (“Latency” in the reply). Right after startup it can be -1 until it's measured. |
flags: MessageFlags.Ephemeral | Only the person who used the command sees the reply. Others in the channel don't. |
A command with a parameter:
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('pozdrav')
.setDescription('Greets the given person.')
.addStringOption((option) =>
option.setName('jmeno').setDescription('Who to greet').setRequired(true),
),
async execute(interaction) {
const jmeno = interaction.options.getString('jmeno');
await interaction.reply(`Hi, ${jmeno}! ${interaction.user.username} says hello.`);
},
};.addStringOption(…)– adds a text parameter (jmeno= name). Discord offers it to the user as ajmeno:field..setRequired(true)– the parameter is required; the command can't be sent without it.interaction.– reads the parameter value.options. getString( 'jmeno') interaction.– the username of the person who used the command.user. username interaction.reply('…')– a public reply in the channel.
Other parameter types: addIntegerOption (whole number), addBooleanOption (yes/no), addUserOption (server member), addChannelOption (channel), addRoleOption (role).
Registering commands#
The deploy-commands.js script loads the definitions of all commands and sends them to Discord. You run it by hand, only when you add a command or change its definition.
const fs = require('node:fs');
const path = require('node:path');
require('dotenv').config({ path: path.join(__dirname, '.env'), quiet: true });
const { REST, Routes } = require('discord.js');
const commands = [];
const foldersPath = path.join(__dirname, 'commands');
for (const folder of fs.readdirSync(foldersPath)) {
const commandsPath = path.join(foldersPath, folder);
for (const file of fs.readdirSync(commandsPath).filter((f) => f.endsWith('.js'))) {
const command = require(path.join(commandsPath, file));
if ('data' in command && 'execute' in command) {
commands.push(command.data.toJSON());
} else {
console.log(`[WARNING] The file ${file} is missing "data" or "execute".`);
}
}
}
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
(async () => {
try {
console.log(`Registering commands: ${commands.length}`);
const data = await rest.put(
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
{ body: commands },
);
console.log(`Done, commands registered: ${data.length}`);
} catch (error) {
console.error(error);
}
})();| Part | What it does |
|---|---|
fs. | Goes through the subfolders of commands (for example utility) and the .js files in them. |
command.data.toJSON() | Converts the command definition to the format the Discord API expects. |
new REST().setToken(…) | A Discord REST API client authenticated with the bot token. It doesn't connect as a bot, it only sends the request. |
Routes. | The route for the commands of a single server (guild). |
rest.put(…, { body: commands }) | Replaces all commands on the server with this list. A command missing from the list disappears from Discord. |
Run it on your computer in the project folder:
node deploy-commands.jsRegistering commands: 2
Done, commands registered: 2Server or global commands?#
| Type | Route | Where they work | When to use |
|---|---|---|---|
| Server (guild) | Routes. | Only on the GUILD_ID server. | Development and testing, a bot for one server. |
| Global | Routes. | On every server where the bot is invited with the applications.commands scope, and by default in direct messages too. | A finished bot for multiple servers. |
If you have the same command registered both for the server and globally, you'll see it twice on your test server. Before switching to global registration, delete the server commands: run the server registration with an empty list (body: []).
Discord limits command creation to 200 per day per server. So don't run the registration script every time the bot starts.
The main bot file#
const fs = require('node:fs');
const path = require('node:path');
require('dotenv').config({ path: path.join(__dirname, '.env'), quiet: true });
const { Client, Collection, Events, GatewayIntentBits, MessageFlags } = require('discord.js');
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
// Load commands from the commands folder
client.commands = new Collection();
const foldersPath = path.join(__dirname, 'commands');
for (const folder of fs.readdirSync(foldersPath)) {
const commandsPath = path.join(foldersPath, folder);
for (const file of fs.readdirSync(commandsPath).filter((f) => f.endsWith('.js'))) {
const command = require(path.join(commandsPath, file));
if ('data' in command && 'execute' in command) {
client.commands.set(command.data.name, command);
}
}
}
client.once(Events.ClientReady, (readyClient) => {
console.log(`Ready! Logged in as ${readyClient.user.tag}`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
const command = interaction.client.commands.get(interaction.commandName);
if (!command) {
console.error(`Command ${interaction.commandName} doesn't exist.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
const odpoved = { content: 'There was an error while running this command.', flags: MessageFlags.Ephemeral };
if (interaction.replied || interaction.deferred) {
await interaction.followUp(odpoved);
} else {
await interaction.reply(odpoved);
}
}
});
client.login(process.env.DISCORD_TOKEN);| Part | What it does |
|---|---|
new Client({ intents: [GatewayIntentBits. | Creates the bot. The Guilds intent is enough for slash commands and isn't privileged. Reading regular messages would need GuildMessages and MessageContent. |
client.commands = new Collection() | A list of commands keyed by name. Collection is an extended Map from discord.js. |
client. | Stores the command under its name, for example ping. |
client. | Runs once after a successful login. readyClient.user.tag is the bot's name. |
client. | Runs on every interaction (command, button, form). |
interaction. | Lets through only slash commands and ignores other interactions. |
client. | Finds the command file by the name the user used. |
try { … } catch (error) { … } | An error in a command must not crash the bot. The user gets an error reply and the error is printed to the console. |
interaction.replied / interaction.deferred | When the bot has already replied or deferred the reply, it has to send any further message with followUp. |
client. | Logs the bot in with the token from .env. |
Try the bot on your computer:
node index.jsReady! Logged in as MujBot#1234On Discord, type /ping and pick your bot's command.
Replying to a slow command#
When a command needs more than 3 seconds (a database query, a call to another API), defer the reply first and send the result later:
async execute(interaction) {
await interaction.deferReply();
const vysledek = await nejakaDlouhaPrace();
await interaction.editReply(`Done: ${vysledek}`);
}interaction.deferReply()– acknowledges the interaction right away. The user sees that the bot is thinking. For a reply only they can see, usedeferReply({ flags: MessageFlags.Ephemeral }).interaction.editReply(…)– replaces “thinking” with the actual reply. You have to send a deferred reply within 15 minutes.nejakaDlouhaPrace()– your own function; the name is just an example.
Replying with an embed#
An embed is a message with a colored stripe, a title and fields. It's good for presenting information clearly. The /server command shows details about the server:
const { SlashCommandBuilder, EmbedBuilder, InteractionContextType } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('server')
.setDescription('Information about this Discord server.')
.setContexts(InteractionContextType.Guild),
async execute(interaction) {
const guild = interaction.guild;
const embed = new EmbedBuilder()
.setColor(0xe11d48)
.setTitle(guild.name)
.addFields(
{ name: 'Members', value: String(guild.memberCount), inline: true },
{ name: 'Created', value: guild.createdAt.toLocaleDateString('en-US'), inline: true },
)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
},
};| Part | What it does |
|---|---|
.setContexts( | The command can only be used on a server, not in direct messages (where interaction.guild wouldn't exist). |
new EmbedBuilder() | Starts building an embed. |
.setColor(0xe11d48) | The stripe color as a hexadecimal number (red here). |
.setTitle(guild.name) | The title, here the server name. |
.addFields({ name, value, inline }) | Fields with a label and a value. value must be text, hence String(…). inline: true puts the fields side by side. |
guild.memberCount | The server's member count. Works with the Guilds intent, no privileged intent needed. |
.setTimestamp() | Adds the current time to the footer. |
interaction.reply({ embeds: [embed] }) | Sends the embed. You can put several embeds in one message as an array. |
After adding the file, run node deploy-commands.js again; the new command has to be registered.
Who can use a command#
You set the default permissions right in the command definition:
const { SlashCommandBuilder, PermissionFlagsBits, InteractionContextType, MessageFlags } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('smazat')
.setDescription('Deletes the latest messages in the channel.')
.addIntegerOption((o) =>
o.setName('pocet').setDescription('How many messages (1–100)').setMinValue(1).setMaxValue(100).setRequired(true),
)
.setDefaultMemberPermissions(PermissionFlagsBits.ManageMessages)
.setContexts(InteractionContextType.Guild),
async execute(interaction) {
const pocet = interaction.options.getInteger('pocet');
const smazane = await interaction.channel.bulkDelete(pocet, true);
await interaction.reply({ content: `Messages deleted: ${smazane.size}`, flags: MessageFlags.Ephemeral });
},
};.setDefaultMemberPermissions(– by default, only members with the Manage Messages permission see the command. A server admin can change this in the server settings (Integrations).PermissionFlagsBits. ManageMessages) .setMinValue(1)and.setMaxValue(100)– Discord won't accept a number outside the range.interaction.– deletes messages in bulk.channel. bulkDelete( pocet, true) trueskips messages older than 14 days, which can't be bulk deleted.- The bot itself needs the Manage Messages permission in the channel, otherwise it fails with
DiscordAPIError[50013]: Missing Permissions.
No command setting stops members with the Administrator permission. So register commands meant only for you (for example eval) only on a private test server.
Event: welcoming a new member#
Besides commands, a bot reacts to events. Welcoming a new member needs the privileged Server Members Intent: turn it on in the Developer Portal on the Bot page and add it to your code.
const { Events } = require('discord.js');
module.exports = {
name: Events.GuildMemberAdd,
once: false,
async execute(member) {
const kanal = member.guild.systemChannel;
if (!kanal) return;
await kanal.send(`Welcome to the server, ${member}!`);
},
};In index.js, update the intents and add event loading (after the command loading):
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
// … command loading …
const eventsPath = path.join(__dirname, 'events');
for (const file of fs.readdirSync(eventsPath).filter((f) => f.endsWith('.js'))) {
const event = require(path.join(eventsPath, file));
if (event.once) {
client.once(event.name, (...args) => event.execute(...args));
} else {
client.on(event.name, (...args) => event.execute(...args));
}
}GatewayIntentBits.– Discord then sends events when members join and leave. If the intent isn't enabled in the Developer Portal, the bot reportsGuildMembers Used disallowed intents.member.– the server's system channel (where Discord posts welcome messages). If the server doesn't have one, the bot sends nothing.guild. systemChannel ${member}– inserts a mention of the new member (@name).client.once/client.on– an event withonce: trueis handled only once (bot startup), the others every time.
Structuring a larger bot#
- Commands grouped by category in folders such as
commands/moderace,commands/zabava… The loading code above goes through all of them. - Events in the
eventsfolder, each in its own file withname,onceandexecute(see above). You can move theClientReadyandInteractionCreatehandlers toeventstoo. - Configuration (channel and role IDs) in
config.json, secrets in.env. - Data (points, warnings) in a database, not in variables in memory, which are lost on restart.
Running it at StormHost#
- Run the command registration (
node deploy-commands.js) on your computer. You don't need to run it on the server. - Stop the bot on your computer (Ctrl+C). Two running copies with the same token steal each other's interactions.
- Upload
index.js,package.json,package-lock.jsonand thecommandsfolder with all its subfolders in the Files tab. If you added the new member welcome, upload theeventsfolder too. Without it, the bot crashes at startup withENOENT: no such file or directory. Create.envdirectly on the server. Steps: uploading and starting the bot. - In the Settings tab, check that the main file is
index.js. - In the Console tab, click Start.
Verification#
- The Console shows
Ready! Logged in as …and the bot is online on Discord. - When you type
/on your test server, the menu showspingandpozdravwith your bot's icon. /pingreplies with a message only you can see,/pozdrav jmeno:Janwith a public message.
Troubleshooting#
Commands don't show up in the menu#
- The registration didn't run: run
node deploy-commands.jsand check for theDone, commands registered: …output. GUILD_IDis the ID of a different server than the one you're testing on.- The bot was invited without the
applications.commandsscope. Invite it again with a link that has bothbotandapplications.commands. - Discord caches the menu. Reload the Discord app (Ctrl+R).
Registration fails with DiscordAPIError[50001]: Missing Access#
The bot isn't on the GUILD_ID server, or doesn't have the applications.commands scope there. Check the server ID and invite the bot again.
Discord says “The application did not respond”#
The bot didn't reply within 3 seconds. Either it isn't running (check the Console), the command takes long and deferReply() is missing, or an error happened in the command before the reply (you'll find it in the Console).
DiscordAPIError[10062]: Unknown interaction#
The reply came too late (after 3 seconds), or two copies of the bot are running with the same token and the other one already handled the interaction. Use deferReply() and keep only one copy running.
DiscordAPIError[40060]: Interaction has already been acknowledged#
You called reply() twice on one interaction. Send the second message with followUp(), or use editReply() after deferReply().
[WARNING] The file … is missing "data" or "execute".#
A file in the commands folder doesn't export both parts. Check module.exports = { data, execute } and look for typos.
require is not defined in ES module scope or Cannot use import statement outside a module#
You're mixing CommonJS and ES modules. The examples are for CommonJS: package.json must have "type": "commonjs" (or no type field at all). Details in the logs and errors guide.
Summary
- A slash command = a definition (
SlashCommandBuilder), registration (deploy-commands.jsover REST) and handling (Events.InteractionCreate). - Run the registration only when a definition changes. During development, register per server (
applicationGuildCommands); register a finished bot globally (applicationCommands). - Reply to an interaction within 3 seconds. Start longer work with
deferReply()and finish it witheditReply(). - Catch errors in commands with
try/catchso one error doesn't crash the bot. Never run two copies with the same token.