We schrijven een bot met de bibliotheek discord.js en twee slash-commando's: /ping en /pozdrav (“begroet”) met een parameter. We leggen elke regel uit en starten de bot ten slotte bij StormHost.
Wat je nodig hebt
- Node.js in de LTS-versie op je computer (download op nodejs.org (opent in een nieuw venster)) en een code-editor, bijvoorbeeld Visual Studio Code
- Een applicatie in het Discord Developer Portal, een token en de bot uitgenodigd op je testserver met de scope
applications.commands(handleiding) - Een Discord-bot bij StormHost om de bot continu te laten draaien
- Ongeveer 30 minuten
Hoe het werkt#
Een bot met slash-commando's bestaat uit drie delen:
- Definitie van de commando's – de naam, de beschrijving en de parameters van het commando. Je meldt ze één keer bij Discord aan met een apart script via de REST API (registratie).
- De draaiende bot – maakt verbinding met Discord (de gateway) en wacht op gebeurtenissen.
- Afhandeling van de interactie – als iemand een commando gebruikt, stuurt Discord de bot een interactie en moet de bot daar binnen 3 seconden op antwoorden. Anders ziet de gebruiker dat de applicatie niet heeft gereageerd.
Het registreren en het draaien van de bot staan los van elkaar: je hoeft de commando's alleen opnieuw te registreren als je hun definitie wijzigt (naam, beschrijving, parameters). Voor wijzigingen in de code van het antwoord is geen registratie nodig.
Het project opzetten#
Maak een map
muj-botaan en open daarin een terminal (in VS Code Ctrl+`).Maak het project aan en installeer de bibliotheken:
bash npm init -y npm install discord.js dotenvnpm init -y– maaktpackage.jsonaan met standaardwaarden (-y= beantwoordt alle vragen met “ja”).npm install discord.js dotenv– installeert discord.js en dotenv (voor het laden van het bestand.env) en zet ze independencies.
Stel in
package.json"main": "index.js"en"type": "commonjs"in. De voorbeelden gebruikenrequire(). Wat de velden betekenen, lees je in de handleiding de bot uploaden en starten.Maak het bestand
.envaan:.envini DISCORD_TOKEN=<BOT_TOKEN> CLIENT_ID=<APPLICATION_ID> GUILD_ID=<SERVER_ID>Vervang
<BOT_TOKEN>door het token van je bot,<APPLICATION_ID>door het Application ID van de pagina General Information in het Developer Portal en<SERVER_ID>door het ID van je Discord-testserver. Waar je ze vindt, staat in de handleiding token en intents.Maak een
.gitignoreaan met de twee regelsnode_modulesen.env.
De uiteindelijke structuur:
muj-bot/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
├── index.js
├── deploy-commands.js
└── commands/
└── utility/
├── ping.js
└── pozdrav.jsCommando's#
Elk commando heeft een eigen bestand met twee delen: data (de definitie voor Discord) en execute (wat er gebeurt als het commando wordt gebruikt).
const { SlashCommandBuilder, MessageFlags } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('ping')
.setDescription('Antwoordt met Pong! en toont de reactietijd van de bot.'),
async execute(interaction) {
await interaction.reply({
content: `Pong! Reactietijd: ${interaction.client.ws.ping} ms`,
flags: MessageFlags.Ephemeral,
});
},
};| Regel | Wat die doet |
|---|---|
require('discord.js') | Laadt uit de bibliotheek de klasse SlashCommandBuilder (bouwt de definitie van het commando op) en MessageFlags (berichtvlaggen). |
.setName('ping') | De naam van het commando, die de gebruiker na de slash typt. 1–32 tekens, alleen kleine letters, cijfers, - en _, zonder spaties. |
.setDescription('…') | De beschrijving in het commandomenu. Verplicht, 1–100 tekens. |
async execute(interaction) | De functie die de bot aanroept wanneer het commando wordt gebruikt. interaction bevat alles over dat gebruik (wie, waar, parameters). |
interaction.reply({ … }) | Het antwoord op het commando. Het moet binnen 3 seconden komen. |
interaction. | De reactietijd van de verbinding van de bot met Discord in milliseconden. Direct na het starten kan dit -1 zijn, tot de waarde is gemeten. |
flags: MessageFlags.Ephemeral | Alleen wie het commando heeft gebruikt, ziet het antwoord. Anderen in het kanaal zien het niet. |
Een commando met een parameter:
const { SlashCommandBuilder } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('pozdrav')
.setDescription('Begroet de opgegeven persoon.')
.addStringOption((option) =>
option.setName('jmeno').setDescription('Wie je wilt begroeten').setRequired(true),
),
async execute(interaction) {
const jmeno = interaction.options.getString('jmeno');
await interaction.reply(`Hoi, ${jmeno}! Groeten van ${interaction.user.username}.`);
},
};.addStringOption(…)– voegt een tekstparameter toe (jmeno= naam). Discord biedt die de gebruiker aan als het veldjmeno:..setRequired(true)– de parameter is verplicht, zonder kan het commando niet worden verstuurd.interaction.– leest de waarde van de parameter.options. getString( 'jmeno') interaction.– de gebruikersnaam van wie het commando heeft gebruikt.user. username interaction.reply('…')– een openbaar antwoord in het kanaal.
Andere parametertypen: addIntegerOption (geheel getal), addBooleanOption (ja/nee), addUserOption (serverlid), addChannelOption (kanaal), addRoleOption (rol).
Commando's registreren#
Het script deploy-commands.js laadt de definities van alle commando's en stuurt ze naar Discord. Je start het handmatig, alleen wanneer je een commando toevoegt of de definitie ervan wijzigt.
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] Het bestand ${file} heeft geen "data" of "execute".`);
}
}
}
const rest = new REST().setToken(process.env.DISCORD_TOKEN);
(async () => {
try {
console.log(`Commando's registreren: ${commands.length}`);
const data = await rest.put(
Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
{ body: commands },
);
console.log(`Klaar, geregistreerde commando's: ${data.length}`);
} catch (error) {
console.error(error);
}
})();| Onderdeel | Wat het doet |
|---|---|
fs. | Loopt de submappen van commands langs (bijvoorbeeld utility) en daarin de .js-bestanden. |
command.data.toJSON() | Zet de definitie van het commando om naar de vorm die de Discord API verwacht. |
new REST().setToken(…) | Een client voor de REST API van Discord, aangemeld met het token van de bot. Hij maakt geen verbinding als bot, maar verstuurt alleen het verzoek. |
Routes. | Het adres voor de commando's van één server (guild). |
rest.put(…, { body: commands }) | Vervangt alle commando's op de server door deze lijst. Een commando dat in de lijst ontbreekt, verdwijnt uit Discord. |
Start het op je computer in de projectmap:
node deploy-commands.jsCommando's registreren: 2
Klaar, geregistreerde commando's: 2Servercommando's of globale commando's?#
| Soort | Route | Waar ze werken | Wanneer gebruiken |
|---|---|---|---|
| Server (guild) | Routes. | Alleen op de server GUILD_ID. | Ontwikkelen en testen, een bot voor één server. |
| Globaal | Routes. | Op alle servers waar de bot is uitgenodigd met de scope applications.commands, en standaard ook in privéberichten. | Een afgeronde bot voor meerdere servers. |
Als je hetzelfde commando zowel voor de server als globaal hebt geregistreerd, zie je het op de testserver twee keer. Verwijder de servercommando's voordat je overstapt op globale registratie: start de serverregistratie met een lege lijst (body: []).
Discord beperkt het aanmaken van commando's tot 200 per dag per server. Start het registratiescript daarom niet bij elke start van de bot.
Het hoofdbestand van de bot#
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] });
// Commando's laden uit de map commands
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(`Het commando ${interaction.commandName} bestaat niet.`);
return;
}
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
const odpoved = { content: 'Er is een fout opgetreden bij het uitvoeren van het commando.', flags: MessageFlags.Ephemeral };
if (interaction.replied || interaction.deferred) {
await interaction.followUp(odpoved);
} else {
await interaction.reply(odpoved);
}
}
});
client.login(process.env.DISCORD_TOKEN);| Onderdeel | Wat het doet |
|---|---|
new Client({ intents: [GatewayIntentBits. | Maakt de bot aan. De intent Guilds is genoeg voor slash-commando's en is niet geprivilegieerd. Voor het lezen van gewone berichten zouden GuildMessages en MessageContent nodig zijn. |
client.commands = new Collection() | De lijst met commando's op naam. Collection is een uitgebreide Map uit discord.js. |
client. | Slaat het commando op onder zijn naam, bijvoorbeeld ping. |
client. | Wordt één keer uitgevoerd na een geslaagde login. readyClient.user.tag is de naam van de bot. |
client. | Wordt bij elke interactie uitgevoerd (commando, knop, formulier). |
interaction. | Laat alleen slash-commando's door en negeert andere interacties. |
client. | Zoekt het bestand van het commando op aan de hand van de naam die de gebruiker heeft gebruikt. |
try { … } catch (error) { … } | Een fout in een commando mag de bot niet laten crashen. De gebruiker krijgt een foutmelding als antwoord en de fout wordt naar de console geschreven. |
interaction.replied / interaction.deferred | Als de bot al heeft geantwoord of het antwoord heeft uitgesteld, moet hij een volgend bericht via followUp sturen. |
client. | Logt de bot in met het token uit .env. |
Probeer de bot uit op je computer:
node index.jsReady! Logged in as MujBot#1234Typ op Discord /ping en kies het commando van je bot.
Antwoorden op een commando dat langer duurt#
Als een commando meer dan 3 seconden nodig heeft (een databasequery, een aanroep van een andere API), stel je het antwoord eerst uit en stuur je het resultaat later:
async execute(interaction) {
await interaction.deferReply();
const vysledek = await nejakaDlouhaPrace();
await interaction.editReply(`Klaar: ${vysledek}`);
}interaction.deferReply()– bevestigt de ontvangst meteen. De gebruiker ziet “de bot denkt na”. Gebruik voor een antwoord dat alleen de gebruiker zelf zietdeferReply({ flags: MessageFlags.Ephemeral }).interaction.editReply(…)– vervangt “denkt na” door het echte antwoord. Een uitgesteld antwoord moet je binnen 15 minuten versturen.nejakaDlouhaPrace()– je eigen functie, de naam is een voorbeeld.
Antwoorden met een embed#
Een embed is een bericht met een gekleurde streep, een titel en velden. Handig om informatie overzichtelijk te tonen. Het commando /server toont gegevens over de server:
const { SlashCommandBuilder, EmbedBuilder, InteractionContextType } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('server')
.setDescription('Informatie over deze Discord-server.')
.setContexts(InteractionContextType.Guild),
async execute(interaction) {
const guild = interaction.guild;
const embed = new EmbedBuilder()
.setColor(0xe11d48)
.setTitle(guild.name)
.addFields(
{ name: 'Leden', value: String(guild.memberCount), inline: true },
{ name: 'Aangemaakt', value: guild.createdAt.toLocaleDateString('nl-NL'), inline: true },
)
.setTimestamp();
await interaction.reply({ embeds: [embed] });
},
};| Onderdeel | Wat het doet |
|---|---|
.setContexts( | Het commando kan alleen op een server worden gebruikt, niet in privéberichten (daar zou interaction.guild niet bestaan). |
new EmbedBuilder() | Begint met het opbouwen van de embed. |
.setColor(0xe11d48) | De kleur van de streep als hexadecimaal getal (hier rood). |
.setTitle(guild.name) | De titel, hier de naam van de server. |
.addFields({ name, value, inline }) | Velden met een label en een waarde. value moet tekst zijn, vandaar String(…). inline: true zet de velden naast elkaar. |
guild.memberCount | Het aantal leden van de server. Werkt met de intent Guilds, zonder geprivilegieerde intent. |
.setTimestamp() | Voegt de huidige tijd toe aan de voettekst. |
interaction.reply({ embeds: [embed] }) | Verstuurt de embed. In één bericht kun je meerdere embeds in de array zetten. |
Start na het toevoegen van het bestand opnieuw node deploy-commands.js, het nieuwe commando moet worden geregistreerd.
Wie een commando mag gebruiken#
De standaardrechten stel je direct in de definitie van het commando in:
const { SlashCommandBuilder, PermissionFlagsBits, InteractionContextType, MessageFlags } = require('discord.js');
module.exports = {
data: new SlashCommandBuilder()
.setName('smazat')
.setDescription('Verwijdert de laatste berichten in het kanaal.')
.addIntegerOption((o) =>
o.setName('pocet').setDescription('Hoeveel berichten (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: `Verwijderde berichten: ${smazane.size}`, flags: MessageFlags.Ephemeral });
},
};.setDefaultMemberPermissions(– standaard zien alleen leden met het recht Berichten beheren het commando. Een beheerder van de server kan dat in de serverinstellingen (Integraties) aanpassen.PermissionFlagsBits. ManageMessages) .setMinValue(1)en.setMaxValue(100)– Discord staat geen getal buiten het bereik toe.interaction.– verwijdert berichten in bulk.channel. bulkDelete( pocet, true) trueslaat berichten ouder dan 14 dagen over, want die kunnen niet in bulk worden verwijderd.- De bot zelf heeft in het kanaal het recht Manage Messages nodig, anders eindigt het commando met
DiscordAPIError[50013]: Missing Permissions.
Leden met het recht Administrator worden door geen enkele instelling van het commando tegengehouden. Registreer commando's die alleen voor jou bedoeld zijn (bijvoorbeeld eval) daarom alleen op een besloten testserver.
Gebeurtenis: een nieuw lid verwelkomen#
Naast commando's reageert de bot op gebeurtenissen. Voor het verwelkomen van een nieuw lid is de geprivilegieerde intent Server Members Intent nodig: zet die aan in het Developer Portal op de pagina Bot en voeg hem toe aan de 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(`Welkom op de server, ${member}!`);
},
};Pas in index.js de intents aan en voeg het laden van de gebeurtenissen toe (na het laden van de commando's):
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
// … commando's laden …
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 stuurt dan gebeurtenissen wanneer leden binnenkomen en vertrekken. Als de intent niet is aangezet in het Developer Portal, meldt de botGuildMembers Used disallowed intents.member.– het systeemkanaal van de server (waar Discord welkomstberichten plaatst). Als de server er geen heeft, stuurt de bot niets.guild. systemChannel ${member}– voegt een vermelding van het nieuwe lid in (@naam).client.once/client.on– een gebeurtenis metonce: truewordt maar één keer verwerkt (start van de bot), de andere elke keer.
Structuur van een grotere bot#
- Commando's per categorie in de mappen
commands/moderace,commands/zabava… De laadcode hierboven loopt ze allemaal langs. - Gebeurtenissen in de map
events, elk in een eigen bestand metname,onceenexecute(zie hierboven). Ook de afhandeling vanClientReadyenInteractionCreatekun je naareventsverplaatsen. - Configuratie (ID's van kanalen en rollen) in
config.json, geheime gegevens in.env. - Gegevens (punten, waarschuwingen) in een database, niet in variabelen in het geheugen, want die gaan bij een herstart verloren.
Starten bij StormHost#
- Voer de registratie van de commando's (
node deploy-commands.js) uit op je computer. Op de server is dat niet nodig. - Stop de bot op je computer (Ctrl+C). Twee draaiende kopieën met hetzelfde token stelen elkaars interacties.
- Upload
index.js,package.json,package-lock.jsonen de mapcommandsmet alle submappen op het tabblad Bestanden. Heb je het verwelkomen van een nieuw lid toegevoegd, upload dan ook de mapevents. Zonder die map crasht de bot bij het starten met de foutENOENT: no such file or directory. Maak.envdirect op de server aan. Zo doe je dat: de bot uploaden en starten. - Controleer op het tabblad Instellingen of het hoofdbestand
index.jsis. - Klik op het tabblad Console op Starten.
Controle#
- In de Console staat
Ready! Logged in as …en de bot is online op Discord. - Als je op de testserver
/typt, bevat het menupingenpozdravmet het pictogram van je bot. /pingantwoordt met een bericht dat alleen jij ziet,/pozdrav jmeno:Janmet een openbaar bericht.
Als iets niet werkt#
De commando's verschijnen niet in het menu#
- De registratie is niet uitgevoerd: start
node deploy-commands.jsen controleer de uitvoerKlaar, geregistreerde commando's: …. GUILD_IDis het ID van een andere server dan die waarop je test.- De bot is uitgenodigd zonder de scope
applications.commands. Nodig hem opnieuw uit met een link met zowelbotalsapplications.commands. - Discord onthoudt het menu. Herstart de Discord-app (Ctrl+R).
De registratie eindigt met DiscordAPIError[50001]: Missing Access#
De bot zit niet op de server GUILD_ID, of heeft daar de scope applications.commands niet. Controleer het ID van de server en nodig de bot opnieuw uit.
Discord meldt “The application did not respond” (de applicatie reageerde niet)#
De bot heeft niet binnen 3 seconden geantwoord. Of hij draait niet (controleer de Console), of het commando duurt lang en deferReply() ontbreekt, of er is in het commando een fout opgetreden vóór het antwoord (die vind je in de Console).
DiscordAPIError[10062]: Unknown interaction#
Het antwoord kwam te laat (na 3 seconden), of er draaien twee kopieën van de bot met hetzelfde token en de andere heeft de interactie al verwerkt. Gebruik deferReply() en laat maar één kopie draaien.
DiscordAPIError[40060]: Interaction has already been acknowledged#
Je hebt voor één interactie twee keer reply() aangeroepen. Stuur het tweede bericht via followUp(), of gebruik na deferReply() editReply().
[WARNING] Het bestand … heeft geen "data" of "execute".#
Een bestand in de map commands exporteert niet beide delen. Controleer module.exports = { data, execute } en kijk of er typefouten in staan.
require is not defined in ES module scope of Cannot use import statement outside a module#
CommonJS en ES-modules worden door elkaar gebruikt. De voorbeelden zijn voor CommonJS: in package.json moet "type": "commonjs" staan (of het veld type ontbreekt). Meer details vind je in de handleiding logs en fouten.
Samenvatting
- Slash-commando = definitie (
SlashCommandBuilder), registratie (deploy-commands.jsvia REST) en afhandeling (Events.InteractionCreate). - Start de registratie alleen als de definitie verandert. Tijdens het ontwikkelen per server (
applicationGuildCommands), een afgeronde bot globaal (applicationCommands). - Antwoord binnen 3 seconden op een interactie. Begin langer werk met
deferReply()en rond het af meteditReply(). - Vang fouten in commando's af met
try/catch, zodat één fout de bot niet laat crashen. Start nooit twee kopieën met hetzelfde token.