From 84f1b1890de5add805bef1a030b0ade3c6aca213 Mon Sep 17 00:00:00 2001 From: Suneet Tipirneni <77477100+suneettipirneni@users.noreply.github.com> Date: Sun, 16 Jul 2023 13:13:18 -0400 Subject: [PATCH] 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 --- packages/create-discord-bot/package.json | 4 +- .../src/create-discord-bot.ts | 119 ++++++++++-------- packages/create-discord-bot/src/index.ts | 39 ++++++ .../template/JavaScript/.env | 1 + .../template/JavaScript/.eslintrc.json | 7 +- .../template/JavaScript/package.json | 4 +- .../template/JavaScript/src/commands/index.js | 21 ++++ .../template/JavaScript/src/commands/ping.js | 10 ++ .../template/JavaScript/src/events/index.js | 23 ++++ .../template/JavaScript/src/events/ready.js | 1 + .../template/JavaScript/src/index.js | 17 +-- .../template/JavaScript/src/util/deploy.js | 15 +++ .../template/JavaScript/src/util/loaders.js | 83 ++++++++++++ .../JavaScript/src/util/registerEvents.js | 29 +++++ .../template/TypeScript/.env | 1 + .../template/TypeScript/package.json | 2 + .../template/TypeScript/src/commands/index.ts | 27 ++++ .../template/TypeScript/src/commands/ping.ts | 11 ++ .../template/TypeScript/src/events/index.ts | 30 ++++- .../template/TypeScript/src/index.ts | 19 ++- .../template/TypeScript/src/util/deploy.ts | 15 +++ .../template/TypeScript/src/util/loaders.ts | 76 +++++++++++ .../TypeScript/src/util/registerEvents.ts | 25 ++++ packages/create-discord-bot/tsup.config.ts | 2 +- yarn.lock | 16 ++- 25 files changed, 518 insertions(+), 79 deletions(-) create mode 100644 packages/create-discord-bot/src/index.ts create mode 100644 packages/create-discord-bot/template/JavaScript/src/commands/index.js create mode 100644 packages/create-discord-bot/template/JavaScript/src/commands/ping.js create mode 100644 packages/create-discord-bot/template/JavaScript/src/events/index.js create mode 100644 packages/create-discord-bot/template/JavaScript/src/util/deploy.js create mode 100644 packages/create-discord-bot/template/JavaScript/src/util/loaders.js create mode 100644 packages/create-discord-bot/template/JavaScript/src/util/registerEvents.js create mode 100644 packages/create-discord-bot/template/TypeScript/src/commands/index.ts create mode 100644 packages/create-discord-bot/template/TypeScript/src/commands/ping.ts create mode 100644 packages/create-discord-bot/template/TypeScript/src/util/deploy.ts create mode 100644 packages/create-discord-bot/template/TypeScript/src/util/loaders.ts create mode 100644 packages/create-discord-bot/template/TypeScript/src/util/registerEvents.ts diff --git a/packages/create-discord-bot/package.json b/packages/create-discord-bot/package.json index 3aaa2939f..e60381add 100644 --- a/packages/create-discord-bot/package.json +++ b/packages/create-discord-bot/package.json @@ -10,7 +10,7 @@ "changelog": "git cliff --prepend ./CHANGELOG.md -u -c ./cliff.toml -r ../../ --include-path 'packages/create-discord-bot/*'", "release": "cliff-jumper" }, - "bin": "./dist/create-discord-bot.mjs", + "bin": "./dist/index.mjs", "directories": { "lib": "src" }, @@ -47,12 +47,14 @@ "dependencies": { "chalk": "^5.2.0", "commander": "^10.0.1", + "prompts": "^2.4.2", "validate-npm-package-name": "^5.0.0" }, "devDependencies": { "@favware/cliff-jumper": "^2.0.0", "@microsoft/api-extractor": "^7.35.0", "@types/node": "16.18.32", + "@types/prompts": "^2.4.4", "@types/validate-npm-package-name": "^4.0.0", "@vitest/coverage-c8": "^0.31.1", "cross-env": "^7.0.3", diff --git a/packages/create-discord-bot/src/create-discord-bot.ts b/packages/create-discord-bot/src/create-discord-bot.ts index 82abf8848..d0656c6b3 100755 --- a/packages/create-discord-bot/src/create-discord-bot.ts +++ b/packages/create-discord-bot/src/create-discord-bot.ts @@ -1,70 +1,81 @@ #!/usr/bin/env node // eslint-disable-next-line n/shebang -import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { cp, stat, mkdir, readdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import process from 'node:process'; import { URL } from 'node:url'; import chalk from 'chalk'; -import { program } from 'commander'; import validateProjectName from 'validate-npm-package-name'; import { install, resolvePackageManager } from './helpers/packageManager.js'; import { GUIDE_URL } from './util/constants.js'; -program - .description('Create a basic discord.js bot.') - .option('--typescript', 'Whether to use the TypeScript template.') - .argument('', 'The directory where this will be created.') - .parse(); - -const { typescript } = program.opts(); -const [directory] = program.args; - -if (!directory) { - console.error(chalk.red('Please specify the project directory.')); - process.exit(1); +interface Options { + directory: string; + javascript?: boolean; + typescript?: boolean; } -const root = path.resolve(directory); -const directoryName = path.basename(root); - -if (existsSync(root) && readdirSync(root).length > 0) { - console.error(chalk.red(`The directory ${chalk.yellow(`"${directoryName}"`)} is not empty.`)); - console.error(chalk.red(`Please specify an empty directory.`)); - process.exit(1); -} - -// We'll use the directory name as the project name. Check npm name validity. -const validationResult = validateProjectName(directoryName); - -if (!validationResult.validForNewPackages) { - console.error( - chalk.red( - `Cannot create a project named ${chalk.yellow(`"${directoryName}"`)} due to npm naming restrictions.\n\nErrors:`, - ), - ); - - for (const error of [...(validationResult.errors ?? []), ...(validationResult.warnings ?? [])]) { - console.error(chalk.red(`- ${error}`)); +export async function createDiscordBot({ typescript, javascript, directory }: Options) { + if (!directory) { + console.error(chalk.red('Please specify the project directory.')); + process.exit(1); } - console.error(chalk.red('\nSee https://docs.npmjs.com/cli/configuring-npm/package-json for more details.')); - process.exit(1); + const root = path.resolve(directory); + const directoryName = path.basename(root); + + const directoryStats = await stat(root).catch(async (error) => { + // Create a new directory if the specified one does not exist. + if (error.code === 'ENOENT') { + await mkdir(root, { recursive: true }); + return stat(root); + } + + throw error; + }); + + // If a directory exists and it's not empty, throw an error. + if (directoryStats.isDirectory() && (await readdir(root)).length > 0) { + console.error(chalk.red(`The directory ${chalk.yellow(`"${directoryName}"`)} is not empty.`)); + console.error(chalk.red(`Please specify an empty directory.`)); + process.exit(1); + } + + // We'll use the directory name as the project name. Check npm name validity. + const validationResult = validateProjectName(directoryName); + + if (!validationResult.validForNewPackages) { + console.error( + chalk.red( + `Cannot create a project named ${chalk.yellow( + `"${directoryName}"`, + )} due to npm naming restrictions.\n\nErrors:`, + ), + ); + + for (const error of [...(validationResult.errors ?? []), ...(validationResult.warnings ?? [])]) { + console.error(chalk.red(`- ${error}`)); + } + + console.error(chalk.red('\nSee https://docs.npmjs.com/cli/configuring-npm/package-json for more details.')); + process.exit(1); + } + + console.log(`Creating ${directoryName} in ${chalk.green(root)}.`); + await cp(new URL(`../template/${typescript ? 'TypeScript' : 'JavaScript'}`, import.meta.url), root, { + recursive: true, + }); + + process.chdir(root); + + const newPackageJSON = await readFile('./package.json', { encoding: 'utf8' }).then((str) => + str.replace('[REPLACE-NAME]', directoryName), + ); + await writeFile('./package.json', newPackageJSON); + + const packageManager = resolvePackageManager(); + install(packageManager); + console.log(chalk.green('All done! Be sure to read through the discord.js guide for help on your journey.')); + console.log(`Link: ${chalk.cyan(GUIDE_URL)}`); } - -if (!existsSync(root)) { - mkdirSync(root, { recursive: true }); -} - -console.log(`Creating ${directoryName} in ${chalk.green(root)}.`); -cpSync(new URL(`../template/${typescript ? 'TypeScript' : 'JavaScript'}`, import.meta.url), root, { recursive: true }); - -process.chdir(root); - -const newPackageJSON = readFileSync('./package.json', { encoding: 'utf8' }).replace('[REPLACE-NAME]', directoryName); -writeFileSync('./package.json', newPackageJSON); - -const packageManager = resolvePackageManager(); -install(packageManager); -console.log(chalk.green('All done! Be sure to read through the discord.js guide for help on your journey.')); -console.log(`Link: ${chalk.cyan(GUIDE_URL)}`); diff --git a/packages/create-discord-bot/src/index.ts b/packages/create-discord-bot/src/index.ts new file mode 100644 index 000000000..dc8bce8c0 --- /dev/null +++ b/packages/create-discord-bot/src/index.ts @@ -0,0 +1,39 @@ +import { program } from 'commander'; +import prompts from 'prompts'; +import { createDiscordBot } from './create-discord-bot.js'; + +program + .description('Create a basic discord.js bot.') + .option('--directory', 'The directory where this will be created.') + .option('--typescript', 'Whether to use the TypeScript template.') + .option('--javascript', 'Whether to use the JavaScript template.') + .parse(); + +let { typescript, javascript, directory } = program.opts(); + +if (!directory) { + directory = ( + await prompts({ + type: 'text', + name: 'directory', + initial: 'my-bot', + message: 'What is the name of the directory you want to create this project in?', + }) + ).directory; +} + +if (typescript === undefined && javascript === undefined) { + const { useTypescript } = await prompts({ + type: 'toggle', + name: 'useTypescript', + message: 'Do you want to use TypeScript?', + initial: true, + active: 'Yes', + inactive: 'No', + }); + + typescript = useTypescript; + javascript = !useTypescript; +} + +await createDiscordBot({ typescript, javascript, directory }); diff --git a/packages/create-discord-bot/template/JavaScript/.env b/packages/create-discord-bot/template/JavaScript/.env index 47b0cab27..b9edc2b71 100644 --- a/packages/create-discord-bot/template/JavaScript/.env +++ b/packages/create-discord-bot/template/JavaScript/.env @@ -1 +1,2 @@ DISCORD_TOKEN= +APPLICATION_ID= diff --git a/packages/create-discord-bot/template/JavaScript/.eslintrc.json b/packages/create-discord-bot/template/JavaScript/.eslintrc.json index d5666a755..ddd781aec 100644 --- a/packages/create-discord-bot/template/JavaScript/.eslintrc.json +++ b/packages/create-discord-bot/template/JavaScript/.eslintrc.json @@ -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 + } } diff --git a/packages/create-discord-bot/template/JavaScript/package.json b/packages/create-discord-bot/template/JavaScript/package.json index 0a8e2cf79..c81cf9a1f 100644 --- a/packages/create-discord-bot/template/JavaScript/package.json +++ b/packages/create-discord-bot/template/JavaScript/package.json @@ -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" }, diff --git a/packages/create-discord-bot/template/JavaScript/src/commands/index.js b/packages/create-discord-bot/template/JavaScript/src/commands/index.js new file mode 100644 index 000000000..ea7dbf22c --- /dev/null +++ b/packages/create-discord-bot/template/JavaScript/src/commands/index.js @@ -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} 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} + * @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'; diff --git a/packages/create-discord-bot/template/JavaScript/src/commands/ping.js b/packages/create-discord-bot/template/JavaScript/src/commands/ping.js new file mode 100644 index 000000000..8a30f80ec --- /dev/null +++ b/packages/create-discord-bot/template/JavaScript/src/commands/ping.js @@ -0,0 +1,10 @@ +/** @type {import('./index.js').Command} */ +export default { + data: { + name: 'ping', + description: 'Ping!', + }, + async execute(interaction) { + await interaction.reply('Pong!'); + }, +}; diff --git a/packages/create-discord-bot/template/JavaScript/src/events/index.js b/packages/create-discord-bot/template/JavaScript/src/events/index.js new file mode 100644 index 000000000..57b2c6e4e --- /dev/null +++ b/packages/create-discord-bot/template/JavaScript/src/events/index.js @@ -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} 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} + * @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'; diff --git a/packages/create-discord-bot/template/JavaScript/src/events/ready.js b/packages/create-discord-bot/template/JavaScript/src/events/ready.js index 679048d90..8b93cfd94 100644 --- a/packages/create-discord-bot/template/JavaScript/src/events/ready.js +++ b/packages/create-discord-bot/template/JavaScript/src/events/ready.js @@ -1,5 +1,6 @@ import { Events } from 'discord.js'; +/** @type {import('./index.js').Event} */ export default { name: Events.ClientReady, once: true, diff --git a/packages/create-discord-bot/template/JavaScript/src/index.js b/packages/create-discord-bot/template/JavaScript/src/index.js index 850544338..d45b56ef9 100644 --- a/packages/create-discord-bot/template/JavaScript/src/index.js +++ b/packages/create-discord-bot/template/JavaScript/src/index.js @@ -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(); diff --git a/packages/create-discord-bot/template/JavaScript/src/util/deploy.js b/packages/create-discord-bot/template/JavaScript/src/util/deploy.js new file mode 100644 index 000000000..523bdb8ed --- /dev/null +++ b/packages/create-discord-bot/template/JavaScript/src/util/deploy.js @@ -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.`); diff --git a/packages/create-discord-bot/template/JavaScript/src/util/loaders.js b/packages/create-discord-bot/template/JavaScript/src/util/loaders.js new file mode 100644 index 000000000..621134fac --- /dev/null +++ b/packages/create-discord-bot/template/JavaScript/src/util/loaders.js @@ -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} predicate - The predicate to check if the structure is valid + * @param {boolean} recursive - Whether to recursively load the structures in the directory + * @returns {Promise} + */ +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>} + */ +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} + */ +export async function loadEvents(dir, recursive = true) { + return loadStructures(dir, eventPredicate, recursive); +} diff --git a/packages/create-discord-bot/template/JavaScript/src/util/registerEvents.js b/packages/create-discord-bot/template/JavaScript/src/util/registerEvents.js new file mode 100644 index 000000000..b63059679 --- /dev/null +++ b/packages/create-discord-bot/template/JavaScript/src/util/registerEvents.js @@ -0,0 +1,29 @@ +import { Events } from 'discord.js'; + +/** + * @param {Map} 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} */ + 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)); + } +} diff --git a/packages/create-discord-bot/template/TypeScript/.env b/packages/create-discord-bot/template/TypeScript/.env index 47b0cab27..b9edc2b71 100644 --- a/packages/create-discord-bot/template/TypeScript/.env +++ b/packages/create-discord-bot/template/TypeScript/.env @@ -1 +1,2 @@ DISCORD_TOKEN= +APPLICATION_ID= diff --git a/packages/create-discord-bot/template/TypeScript/package.json b/packages/create-discord-bot/template/TypeScript/package.json index bd2bc5ac2..9066fd20c 100644 --- a/packages/create-discord-bot/template/TypeScript/package.json +++ b/packages/create-discord-bot/template/TypeScript/package.json @@ -6,10 +6,12 @@ "scripts": { "test": "tsc", "lint": "prettier --check . && eslint ./src --ext .ts --format=pretty", + "deploy": "node --require dotenv/config dist/util/deploy.js", "format": "prettier --write . && eslint ./src --ext .ts --fix --format=pretty", "start": "node --require dotenv/config dist/index.js" }, "dependencies": { + "@discordjs/core": "^0.6.0", "discord.js": "^14.11.0", "dotenv": "^16.0.3" }, diff --git a/packages/create-discord-bot/template/TypeScript/src/commands/index.ts b/packages/create-discord-bot/template/TypeScript/src/commands/index.ts new file mode 100644 index 000000000..3da2b0739 --- /dev/null +++ b/packages/create-discord-bot/template/TypeScript/src/commands/index.ts @@ -0,0 +1,27 @@ +import type { RESTPostAPIApplicationCommandsJSONBody, CommandInteraction } from 'discord.js'; +import type { StructurePredicate } from '../util/loaders.js'; + +/** + * Defines the structure of a command + */ +export type Command = { + /** + * The data for the command + */ + data: RESTPostAPIApplicationCommandsJSONBody; + /** + * The function to execute when the command is called + * + * @param interaction - The interaction of the command + */ + execute(interaction: CommandInteraction): Promise | void; +}; + +// Defines the predicate to check if an object is a valid Command type +export const predicate: StructurePredicate = (structure): structure is Command => + Boolean(structure) && + typeof structure === 'object' && + 'data' in structure! && + 'execute' in structure && + typeof structure.data === 'object' && + typeof structure.execute === 'function'; diff --git a/packages/create-discord-bot/template/TypeScript/src/commands/ping.ts b/packages/create-discord-bot/template/TypeScript/src/commands/ping.ts new file mode 100644 index 000000000..f9d45a8d1 --- /dev/null +++ b/packages/create-discord-bot/template/TypeScript/src/commands/ping.ts @@ -0,0 +1,11 @@ +import type { Command } from './index.js'; + +export default { + data: { + name: 'ping', + description: 'Ping!', + }, + async execute(interaction) { + await interaction.reply('Pong!'); + }, +} satisfies Command; diff --git a/packages/create-discord-bot/template/TypeScript/src/events/index.ts b/packages/create-discord-bot/template/TypeScript/src/events/index.ts index c4c59332e..f342a9fc2 100644 --- a/packages/create-discord-bot/template/TypeScript/src/events/index.ts +++ b/packages/create-discord-bot/template/TypeScript/src/events/index.ts @@ -1,7 +1,33 @@ import type { ClientEvents } from 'discord.js'; +import type { StructurePredicate } from '../util/loaders.js'; -export interface Event { +/** + * Defines the structure of an event. + */ +export type Event = { + /** + * The function to execute when the event is emitted. + * + * @param parameters - The parameters of the event + */ execute(...parameters: ClientEvents[T]): Promise | void; + /** + * The name of the event to listen to + */ name: T; + /** + * Whether or not the event should only be listened to once + * + * @defaultValue false + */ once?: boolean; -} +}; + +// Defines the predicate to check if an object is a valid Event type. +export const predicate: StructurePredicate = (structure): structure is Event => + Boolean(structure) && + typeof structure === 'object' && + 'name' in structure! && + 'execute' in structure && + typeof structure.name === 'string' && + typeof structure.execute === 'function'; diff --git a/packages/create-discord-bot/template/TypeScript/src/index.ts b/packages/create-discord-bot/template/TypeScript/src/index.ts index 553d57329..d45b56ef9 100644 --- a/packages/create-discord-bot/template/TypeScript/src/index.ts +++ b/packages/create-discord-bot/template/TypeScript/src/index.ts @@ -1,18 +1,17 @@ -import { readdir } from 'node:fs/promises'; import { URL } from 'node:url'; import { Client, GatewayIntentBits } from 'discord.js'; -import type { Event } from './events/index.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') && file !== 'index.js'), -); +// 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)); -for (const file of eventFiles) { - const event: Event = (await import(new URL(file, eventsPath).toString())).default; - client[event.once ? 'once' : 'on'](event.name, async (...args) => event.execute(...args)); -} +// Register the event handlers +registerEvents(commands, events, client); +// Login to the client void client.login(); diff --git a/packages/create-discord-bot/template/TypeScript/src/util/deploy.ts b/packages/create-discord-bot/template/TypeScript/src/util/deploy.ts new file mode 100644 index 000000000..2472cd156 --- /dev/null +++ b/packages/create-discord-bot/template/TypeScript/src/util/deploy.ts @@ -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.`); diff --git a/packages/create-discord-bot/template/TypeScript/src/util/loaders.ts b/packages/create-discord-bot/template/TypeScript/src/util/loaders.ts new file mode 100644 index 000000000..fcd7a5349 --- /dev/null +++ b/packages/create-discord-bot/template/TypeScript/src/util/loaders.ts @@ -0,0 +1,76 @@ +import type { PathLike } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import { URL } from 'node:url'; +import type { Command } from '../commands/index.js'; +import { predicate as commandPredicate } from '../commands/index.js'; +import type { Event } from '../events/index.js'; +import { predicate as eventPredicate } from '../events/index.js'; + +/** + * A predicate to check if the structure is valid + */ +export type StructurePredicate = (structure: unknown) => structure is T; + +/** + * Loads all the structures in the provided directory + * + * @param dir - The directory to load the structures from + * @param predicate - The predicate to check if the structure is valid + * @param recursive - Whether to recursively load the structures in the directory + * @returns + */ +export async function loadStructures( + dir: PathLike, + predicate: StructurePredicate, + recursive = true, +): Promise { + // 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 + const structures: T[] = []; + + // 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; +} + +export async function loadCommands(dir: PathLike, recursive = true): Promise> { + return (await loadStructures(dir, commandPredicate, recursive)).reduce( + (acc, cur) => acc.set(cur.data.name, cur), + new Map(), + ); +} + +export async function loadEvents(dir: PathLike, recursive = true): Promise { + return loadStructures(dir, eventPredicate, recursive); +} diff --git a/packages/create-discord-bot/template/TypeScript/src/util/registerEvents.ts b/packages/create-discord-bot/template/TypeScript/src/util/registerEvents.ts new file mode 100644 index 000000000..4ab7679d8 --- /dev/null +++ b/packages/create-discord-bot/template/TypeScript/src/util/registerEvents.ts @@ -0,0 +1,25 @@ +import { Events, type Client } from 'discord.js'; +import type { Command } from '../commands/index.js'; +import type { Event } from '../events/index.js'; + +export function registerEvents(commands: Map, events: Event[], client: Client): void { + // Create an event to handle command interactions + const interactionCreateEvent: Event = { + 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)); + } +} diff --git a/packages/create-discord-bot/tsup.config.ts b/packages/create-discord-bot/tsup.config.ts index bf763be19..7b4c30510 100644 --- a/packages/create-discord-bot/tsup.config.ts +++ b/packages/create-discord-bot/tsup.config.ts @@ -1,7 +1,7 @@ import { createTsupConfig } from '../../tsup.config.js'; export default createTsupConfig({ - entry: ['src/create-discord-bot.ts'], + entry: ['src/index.ts'], dts: false, format: 'esm', minify: true, diff --git a/yarn.lock b/yarn.lock index e6fc8b6b4..484ca7359 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6723,6 +6723,16 @@ __metadata: languageName: node linkType: hard +"@types/prompts@npm:^2.4.4": + version: 2.4.4 + resolution: "@types/prompts@npm:2.4.4" + dependencies: + "@types/node": "*" + kleur: ^3.0.3 + checksum: fa8d9a6f63f5e7f4a5b9bd4d40527ca4b8c8c6a63bf0864bf72ea85706a9e1b292d73c8c9a3b7423fb80a5d3e7d563d0069d6644384438c1251adbd9efc03a12 + languageName: node + linkType: hard + "@types/prop-types@npm:*": version: 15.7.5 resolution: "@types/prop-types@npm:15.7.5" @@ -10713,6 +10723,7 @@ __metadata: "@favware/cliff-jumper": ^2.0.0 "@microsoft/api-extractor": ^7.35.0 "@types/node": 16.18.32 + "@types/prompts": ^2.4.4 "@types/validate-npm-package-name": ^4.0.0 "@vitest/coverage-c8": ^0.31.1 chalk: ^5.2.0 @@ -10722,12 +10733,13 @@ __metadata: eslint-config-neon: ^0.1.47 eslint-formatter-pretty: ^5.0.0 prettier: ^2.8.8 + prompts: ^2.4.2 tsup: ^6.7.0 typescript: ^5.0.4 validate-npm-package-name: ^5.0.0 vitest: ^0.31.1 bin: - create-discord-bot: ./dist/create-discord-bot.mjs + create-discord-bot: ./dist/index.mjs languageName: unknown linkType: soft @@ -21058,7 +21070,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.0.1, prompts@npm:^2.4.0, prompts@npm:~2.4.2": +"prompts@npm:^2.0.1, prompts@npm:^2.4.0, prompts@npm:^2.4.2, prompts@npm:~2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: