feat(create-discord-bot): Add prompts, command handler and deployment script (#9570)

* feat(create-discord-bot): Add prompts, command handler and deployment script

* fix: revert package template name

* chore: make requested changes

* chore: make requested changes

* fix: remove uneeded listeners

* fix: use `0` for eslint

* fix: remaining requested changes

* chore: requested changes

* fix: remove redundant call
This commit is contained in:
Suneet Tipirneni
2023-07-16 13:13:18 -04:00
committed by GitHub
parent e5effb6f6a
commit 84f1b1890d
25 changed files with 518 additions and 79 deletions

View File

@@ -1 +1,2 @@
DISCORD_TOKEN=
APPLICATION_ID=

View File

@@ -1,4 +1,9 @@
{
"root": true,
"extends": ["neon/common", "neon/node", "neon/prettier"]
"extends": ["neon/common", "neon/node", "neon/prettier"],
"rules": {
"jsdoc/valid-types": 0,
"jsdoc/check-tag-names": 0,
"jsdoc/no-undefined-types": 0
}
}

View File

@@ -6,9 +6,11 @@
"scripts": {
"lint": "prettier --check . && eslint src --ext .js,.cjs --format=pretty",
"format": "prettier --write . && eslint src --ext .js,.cjs --fix --format=pretty",
"start": "node --require dotenv/config src/index.js"
"start": "node --require dotenv/config src/index.js",
"deploy": "node --require dotenv/config src/util/deploy.js"
},
"dependencies": {
"@discordjs/core": "^0.6.0",
"discord.js": "^14.11.0",
"dotenv": "^16.0.3"
},

View File

@@ -0,0 +1,21 @@
/**
* Defines the structure of a command.
*
* @typedef {object} Command
* @property {import('discord.js').RESTPostAPIApplicationCommandsJSONBody} data The data for the command
* @property {(interaction: import('discord.js').CommandInteraction) => Promise<void> | void} execute The function to execute when the command is called
*/
/**
* Defines the predicate to check if an object is a valid Command type.
*
* @type {import('../util/loaders.js').StructurePredicate<Command>}
* @returns {structure is Command}
*/
export const predicate = (structure) =>
Boolean(structure) &&
typeof structure === 'object' &&
'data' in structure &&
'execute' in structure &&
typeof structure.data === 'object' &&
typeof structure.execute === 'function';

View File

@@ -0,0 +1,10 @@
/** @type {import('./index.js').Command} */
export default {
data: {
name: 'ping',
description: 'Ping!',
},
async execute(interaction) {
await interaction.reply('Pong!');
},
};

View File

@@ -0,0 +1,23 @@
/**
* Defines the structure of an event.
*
* @template {keyof import('discord.js').ClientEvents} [T=keyof import('discord.js').ClientEvents]
* @typedef {object} Event
* @property {(...parameters: import('discord.js').ClientEvents[T]) => Promise<void> | void} execute The function to execute the command
* @property {T} name The name of the event to listen to
* @property {boolean} [once] Whether or not the event should only be listened to once
*/
/**
* Defines the predicate to check if an object is a valid Event type.
*
* @type {import('../util/loaders').StructurePredicate<Event>}
* @returns {structure is Event}
*/
export const predicate = (structure) =>
Boolean(structure) &&
typeof structure === 'object' &&
'name' in structure &&
'execute' in structure &&
typeof structure.name === 'string' &&
typeof structure.execute === 'function';

View File

@@ -1,5 +1,6 @@
import { Events } from 'discord.js';
/** @type {import('./index.js').Event<Events.ClientReady>} */
export default {
name: Events.ClientReady,
once: true,

View File

@@ -1,14 +1,17 @@
import { readdir } from 'node:fs/promises';
import { URL } from 'node:url';
import { Client, GatewayIntentBits } from 'discord.js';
import { loadCommands, loadEvents } from './util/loaders.js';
import { registerEvents } from './util/registerEvents.js';
// Initialize the client
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
const eventsPath = new URL('events/', import.meta.url);
const eventFiles = await readdir(eventsPath).then((files) => files.filter((file) => file.endsWith('.js')));
for (const file of eventFiles) {
const event = (await import(new URL(file, eventsPath).toString())).default;
client[event.once ? 'once' : 'on'](event.name, async (...args) => event.execute(...args));
}
// Load the events and commands
const events = await loadEvents(new URL('events/', import.meta.url));
const commands = await loadCommands(new URL('commands/', import.meta.url));
// Register the event handlers
registerEvents(commands, events, client);
// Login to the client
void client.login();

View File

@@ -0,0 +1,15 @@
import process from 'node:process';
import { URL } from 'node:url';
import { API } from '@discordjs/core/http-only';
import { REST } from 'discord.js';
import { loadCommands } from './loaders.js';
const commands = await loadCommands(new URL('commands/', import.meta.url));
const commandData = [...commands.values()].map((command) => command.data);
const rest = new REST({ version: '10' }).setToken(process.env.TOKEN);
const api = new API(rest);
const result = await api.applicationCommands.bulkOverwriteGlobalCommands(process.env.APPLICATION_ID, commandData);
console.log(`Successfully registered ${result.length} commands.`);

View File

@@ -0,0 +1,83 @@
import { readdir, stat } from 'node:fs/promises';
import { URL } from 'node:url';
import { predicate as commandPredicate } from '../commands/index.js';
import { predicate as eventPredicate } from '../events/index.js';
/**
* A predicate to check if the structure is valid.
*
* @template T
* @typedef {(structure: unknown) => structure is T} StructurePredicate
*/
/**
* Loads all the structures in the provided directory.
*
* @template T
* @param {import('node:fs').PathLike} dir - The directory to load the structures from
* @param {StructurePredicate<T>} predicate - The predicate to check if the structure is valid
* @param {boolean} recursive - Whether to recursively load the structures in the directory
* @returns {Promise<T[]>}
*/
export async function loadStructures(dir, predicate, recursive = true) {
// Get the stats of the directory
const statDir = await stat(dir);
// If the provided directory path is not a directory, throw an error
if (!statDir.isDirectory()) {
throw new Error(`The directory '${dir}' is not a directory.`);
}
// Get all the files in the directory
const files = await readdir(dir);
// Create an empty array to store the structures
/** @type {T[]} */
const structures = [];
// Loop through all the files in the directory
for (const file of files) {
// If the file is index.js or the file does not end with .js, skip the file
if (file === 'index.js' || !file.endsWith('.js')) {
continue;
}
// Get the stats of the file
const statFile = await stat(new URL(`${dir}/${file}`));
// If the file is a directory and recursive is true, recursively load the structures in the directory
if (statFile.isDirectory() && recursive) {
structures.push(...(await loadStructures(`${dir}/${file}`, predicate, recursive)));
continue;
}
// Import the structure dynamically from the file
const structure = (await import(`${dir}/${file}`)).default;
// If the structure is a valid structure, add it
if (predicate(structure)) structures.push(structure);
}
return structures;
}
/**
* @param {import('node:fs').PathLike} dir
* @param {boolean} [recursive]
* @returns {Promise<Map<string,import('../commands/index.js').Command>>}
*/
export async function loadCommands(dir, recursive = true) {
return (await loadStructures(dir, commandPredicate, recursive)).reduce(
(acc, cur) => acc.set(cur.data.name, cur),
new Map(),
);
}
/**
* @param {import('node:fs').PathLike} dir
* @param {boolean} [recursive]
* @returns {Promise<import('../events/index.js').Event[]>}
*/
export async function loadEvents(dir, recursive = true) {
return loadStructures(dir, eventPredicate, recursive);
}

View File

@@ -0,0 +1,29 @@
import { Events } from 'discord.js';
/**
* @param {Map<string, import('../commands/index.js').Command>} commands
* @param {import('../events/index.js').Event[]} events
* @param {import('discord.js').Client} client
*/
export function registerEvents(commands, events, client) {
// Create an event to handle command interactions
/** @type {import('../events/index.js').Event<Events.InteractionCreate>} */
const interactionCreateEvent = {
name: Events.InteractionCreate,
async execute(interaction) {
if (interaction.isCommand()) {
const command = commands.get(interaction.commandName);
if (!command) {
throw new Error(`Command '${interaction.commandName}' not found.`);
}
await command.execute(interaction);
}
},
};
for (const event of [...events, interactionCreateEvent]) {
client[event.once ? 'once' : 'on'](event.name, async (...args) => event.execute(...args));
}
}