Skip to content
This repository has been archived by the owner on Jul 23, 2024. It is now read-only.

A framework to facilitate the repetitive tasks when creating discord bots. Used by Greencoast Studios.

License

Notifications You must be signed in to change notification settings

greencoast-studios/discord.js-extended

Repository files navigation

ci-build-status issues bundle-size node-version version djs-version downloads-week downloads-total

Discord.js - Extended

discord.js-extended is a framework that facilitates the repetitive tasks when creating Discord bots with Discord.js. Used by Greencoast Studios.

Heavily inspired by discord.js-commando, it includes similar design decisions, however this adds certain functionality that isn't available like a configuration provider and automatic presence management. This package does not include all functionality provided by Commando, in fact, Commando is way more powerful than this framework. Nevertheless, this was built to make it easier for us to quickly build bots without having to repeat ourselves that much.

This was made for Discord.js v14.11, however any v14 should work fine.

Usage

You can visit the documentation site to see what this framework offers, check the example bot, or check the bots using this section to see even more examples.

Installation

This package does not install Discord.js, so you should install it yourself.

You can install this package with:

npm install discord.js @greencoast/discord.js-extended

Configuring your Client

This package covers client configuration from environment variables and/or JSON files through the ConfigProvider class, which makes it easy to add configuration to a bot. Consider checking the documentation page to see how to use this.

You may also specify custom validators for even more control of how config is provided to your bot. Simply, pass a customValidators property to the ConfigProvider options, and map the key of the config to its validator function. Your validator function should throw a TypeError if the given value is invalid based on your criteria.

An example of a bot's configuration may be as follows:

const path = require('path');
const { ExtendedClient, ConfigProvider } = require('@greencoast/discord.js-extended');
const { IntentsBitField } = require('discord.js');

const config = new ConfigProvider({
  env: process.env, // This adds the environment variables to the config.
  configPath: path.join(__dirname, './config/settings.json'), // This is the location of the JSON file that includes the config.
  default: {
    PREFIX: '!', // Adds a default value for the PREFIX config.
    TOKEN: null, // Adds a default value for the TOKEN config.
    MY_ID: 123,
    OPTIONAL_FLAG: false,
    MY_ENUM: 'enum1'
  },
  types: { // These are the types of the configuration. The provider validates that the config receives the proper configuration types.
    PREFIX: 'string',
    TOKEN: ['string', 'null'], // With a 'null' type, you can pass 'null' to have it as null.
    MY_ID: 'number',
    OPTIONAL_FLAG: ['boolean', 'null'],
    MY_ENUM: 'string',
    MY_NUM_ARRAY: 'number[]' // You can pass arrays through JSON or comma-separated values through env variables.
  },
  customValidators: { // These are the custom validators to use instead of the basic type based validator.
    MY_ENUM: (value) => {
      const validValues = ['enum1', 'enum2', 'enum3'];
      if (!validValues.includes(value)) {
        throw new TypeError(`${value} is not a valid value for MY_ENUM, you should use: ${validValues.join(', ')}`);
      }
    }
  }
});

const client = new ExtendedClient({
  config,
  intents: [IntentsBitField.Flags.Guilds]
});

Make sure to check the documentation page to see the structure of the JSON file required and the type of environment variables that are supported.

Creating a Client

This package exports an extension of the regular Discord.js Client that contains all the extended functionality. You can create one by using the following:

const path = require('path');
const { ExtendedClient, ConfigProvider } = require('@greencoast/discord.js-extended');
const { IntentsBitField } = require('discord.js');

const config = new ConfigProvider({
  env: process.env,
  configPath: path.join(__dirname, './config/settings.json'),
  default: {
    KEY: 'value'
  }
});

const client = new ExtendedClient({
  config, // The config provider instance used by the bot.
  debug: true, // Enable debug-mode.
  owner: '123', // The ID of the bot's owner.
  prefix: '!', // The bot's prefix to be used.
  presence: {
    templates: ['presence 1', 'presence 2', '{custom_key} hi!'], // The presence statuses used by this bot.
    refreshInterval: 3600000, // Update the bot's presence every hour.
    customGetters: {
      custom_key: async() => Math.random().toString() // Define custom getters for keys to replace on the presence strings.
    }
  },
  errorOwnerReporting: true, // Sends DMs to the bot's owner whenever a command throws an error.
  intents: [IntentsBitField.Flags.Guilds]
});

client.login(<YOUR_DISCORD_TOKEN_HERE>);

The presence option in the client's constructor allows you to configure the presence statuses to be used by the bots. These presence statuses may include information from the bot, such as: number of guilds connected to, number of commands, the time the bot went online, or even custom data... Consider checking the documentation page to see what information you can include in your presence statuses and how to add custom getters for your own presence messages.

Adding defaults to your Client

You can register default handlers for the Discord.js Client events, as well as the ExtendedClient custom events.

client.registerDefaultEvents().registerExtraDefaultEvents();

The package also comes with default commands that you can use on your bot. Check the Default Commands to see the available commands you can register right away. You can register them with:

client.registry.registerDefaults();

Adding a Database Provider to your Client

The package comes with persistent data functionality that can be enabled. The DataProvider is an abstract class that serves as common interface to use. By default, data is stored by guild, but you can also store/get data on a global scope.

You can use the following methods:

await client.dataProvider.get(guild, 'key'); // Get a value for 'key' in guild.
await client.dataProvider.set(guild, 'key', 'value'); // Set 'value' for 'key' in guild.
await client.dataProvider.delete(guild, 'key'); // Delete a key-value pair for 'key' in guild.
await client.dataProvider.clear(guild); // Clear all data in a guild.

await client.dataProvider.getGlobal('key'); // Get a value for 'key' in the global scope.
await client.dataProvider.setGlobal('key', 'value'); // Set 'value' for 'key' in the global scope.
await client.dataProvider.deleteGlobal('key'); // Delete a key-value pair for 'key' in the global scope.
await client.dataProvider.clearGlobal(); // Clear all data in the global scope.

You need to set up a data provider before being able to use any of these methods.

Localizing Your Bot

The package contains a Localizer class to help with the localization of your bot. In order to use it, you should pass a localizer object to your client constructor and initialize the localizer in the ready event. Keep in mind that you absolutely need the GUILDS intent in your client for this to work properly.

const client = new ExtendedClient({
  localizer: {
    defaultLocale: 'en', // The default locale for your bot.
    dataProviderKey: 'locale', // The key to be used to store the locale for each guild in the client's data provider.
    localeStrings: locales
  },
  intents: [IntentsBitField.Flags.Guilds]
});

client.on('ready', async() => {
  await client.setDataProvider(new DataProvider()); // Should set the data provider before.
  await client.localizer.init(); // Initializes the localizer.  
});

The localeStrings object should map the name of the locale to another object that maps the message keys with their corresponding message string in its respective language. In the example above, the variable locales could be:

const locales = {
  en: {
    'message.test.hello': 'Hello',
    'message.test.bye': 'Bye',
    'message.test.with_value': 'Hello {name}!'
  },
  es: {
    'message.test.hello': 'Hola',
    'message.test.bye': 'Adios',
    'message.test.with_value': 'Hola {name}!'
  },
  fr: {
    'message.test.hello': 'Bonjour',
    'message.test.bye': 'Au revoir',
    'message.test.with_value': 'Bonjour {name}!'
  }
};

Locale messages should follow the ICU format.

In case a message is not available in a certain locale and it is requested, the message from the default locale will be picked.

Inside a command, you may use the localizer in the following manner:

class MyCommand extends SlashCommand {
  async run(interaction) {
    const localizer = this.client.localizer.getLocalizer(interaction.guild);
    
    interaction.reply(localizer.t('message.test.hello'));
    interaction.reply(localizer.t('message.test.with_value', { name: 'your name' }));
  }
}

This uses the locale saved for the guild. You can change the locale for the guild as such:

class MyCommand extends SlashCommand {
  async run(interaction) {
    const localizer = this.client.localizer.getLocalizer(interaction.guild);
    
    await localizer.updateLocale('fr');
    interaction.reply(`Updated locale to ${localizer.locale}`);
  }
}

If you're outside the context of a guild, you can still use the localizer by using:

client.localizer.t('message.test.with_value', 'es', { name: 'your name' });

Creating Commands

The package contains a RegularCommand to facilitate the creation of commands. In order to create one, you need to create a class that extends RegularCommand and implements a run() method.

const { Permissions } = require('discord.js');
const { RegularCommand } = require('@greencoast/discord.js-extended');

module.exports = class MyCommand extends RegularCommand {
  constructor(client) {
    super(client, {
      name: 'cmd', // The command's name. In this case, users need to write !cmd for the command to work.,
      aliases: ['mycmd', 'alias2'], // The command's aliases. With this, users can write !mycmd and !alias2 for the command to work.
      description: 'My command.', // The command's description.
      group: 'my_group', // The ID of the group that holds this command.
      emoji: ':robot:', // The emoji that represents this command. This is used by the default HelpCommand. Defaults to ':robot:'.
      guildOnly: true, // Whether the command may only be used in a guild. Defaults to false.
      nsfw: false, // Whether the command may only be used in a NSFW channel. Defaults to false.
      ownerOnly: false, // Whether the command may only be used by the owner. Defaults to false.
      userPermissions: Permissions.FLAGS.MANAGE_CHANNELS, // The PermissionResolvable representing the permissions that users require to execute this command. Defaults to null.
      ownerOverride: true, // Whether the owner may execute this command even if they don't have the required permissions. Defaults to true.
    });

    run(message, args) {
      message.reply('Hi!');
    }
  }
}

This command should be saved in a folder with the ID of its group. These folders should be contained in a bigger folder. The folder tree should look like this:

.
├── commands
│   ├── my_group
│   |   ├── MyCommand.js
│   |   ├── AnotherCommand.js
│   ├── other_group
│   |   ├── OtherCommand.js

Once you have this folder structure, you can register your commands in the client:

client.registry
  .registerGroups([
    ['my_group', 'My Group'],
    ['other_group', 'Other Group']
  ])
  .registerCommandsIn('./commands/folder/location');

If you don't register the groups before-hand, the commands will not be registered.

Creating Slash Commands

You can also use Slash Commands, the package contains a SlashCommand to facilitate the creation of slash commands. In order to create one, you need to create a class that extends SlashCommand and implements a run() method.

const { PermissionsBitField, SlashCommandBuilder } = require('discord.js');
const { SlashCommand } = require('@greencoast/discord.js-extended');

module.exports = class MyCommand extends SlashCommand {
  constructor(client) {
    super(client, {
      name: 'cmd', // The command's name. In this case, users need to write !cmd for the command to work.,
      aliases: ['mycmd', 'alias2'], // The command's aliases. With this, users can write !mycmd and !alias2 for the command to work.
      description: 'My command.', // The command's description.
      group: 'my_group', // The ID of the group that holds this command.
      emoji: ':robot:', // The emoji that represents this command. This is used by the default HelpCommand. Defaults to ':robot:'.
      guildOnly: true, // Whether the command may only be used in a guild. Defaults to false.
      nsfw: false, // Whether the command may only be used in a NSFW channel. Defaults to false.
      ownerOnly: false, // Whether the command may only be used by the owner. Defaults to false.
      userPermissions: PermissionsBitField.Flags.ManageChannels, // The PermissionResolvable representing the permissions that users require to execute this command. Defaults to null.
      ownerOverride: true, // Whether the owner may execute this command even if they don't have the required permissions. Defaults to true.
      dataBuilder: new SlashCommandBuilder() // You do not need to use .setName() and .setDescription(), they're handled internally with the data above.
    });

    run(interaction) {
      interaction.reply('Hi!');
    }
  }
}

Keep in mind that you do not need to use SlashCommandBuilder.setName() and SlashCommandBuilder.setDescription() as these are already set by the command constructor.

Deploying Slash Commands

Slash commands require a special procedure to deploy them and have them live on Discord.

Development

For development, it is recommended to have a testing server for the development of the bot. With this in mind, you should set testingGuildID on your client options and have the following ready event handler.

client.on('ready', async() => {
  client.deployer.rest.setToken(config.get('TOKEN'));
  await client.deployer.deployToTestingGuild();
});

This will update the slash commands ONLY on your testing server.

Production

For production, you should have a command deploy script, that could be run by a CI pipeline on a new version of your bot. The following example can work as a potential deploy script.

require('dotenv').config();
const path = require('path');
const { ExtendedClient, ConfigProvider } = require('@greencoast/discord.js-extended');

const config = new ConfigProvider({
  env: process.env,
  configPath: path.join(__dirname, './config/settings.json'),
  types: {
    TOKEN: 'string'
  }
});

const client = new ExtendedClient({
  config,
  intents: []
});

client.registry
  .registerDefaults()
  .registerGroups([
    ['util', 'Utility'],
    ['slash', 'Slash Commands']
  ])
  .registerCommandsIn(path.join(__dirname, './commands'));

client.on('ready', async() => {
  try {
    client.deployer.rest.setToken(config.get('TOKEN'));
    await client.deployer.deployGlobally();
  } catch (error) {
    console.error('Something happened!', error);
    process.exit(1);
  }
});

client.on('commandsDeployed', (commands) => {
  console.log(`Successfully deployed ${commands.length} commands globally!`);
  process.exit(0);
});

client.login(client.config.get('TOKEN'));

Keep in mind that you only need to deploy globally once. Also, this process can take up to an hour to be reflected on Discord. You should not use client.deployer.deployGlobally() for development.

Inviting Your Bot

Inviting your bot requires you to build a specific invite link. Head over to the Discord Applications Page and go into your bot's page. Under the OAuth2 tab, head over to the OAuth2 URL Generator and select (at least) the scopes bot and application.commands. At the bottom, another box should show up where you should pick the corresponding permissions your bot requires to function properly. Once you have all that set, an invite URL will be generated. You should invite the bot to your server (or any server) with this link.

Testing

You can run the unit tests for this package by:

  1. Cloning the repo:
git clone https://github.com/greencoast-studios/discord.js-extended
  1. Installing the dependencies.
npm install
  1. Running the tests.
npm test

Bots Using This

Here's a list of some bots that use this library.

Authors

This library was made by Greencoast Studios.