DEV Community

Cover image for How to implement, test and mock Discord.js v13 slash commands with Typescript
Mark Kop
Mark Kop

Posted on

How to implement, test and mock Discord.js v13 slash commands with Typescript

Situation

If you have a verified Discord Bot running on over 100 servers, you surely have received this email:

Hi there!
We noticed that you are developer with a verified bot, and we wanted to send you a quick reminder about recently announced important changes.
On May 1, 2022 message content and related fields will become a Privileged Intent. You can read more about this here: https://dis.gd/mcfaq.
This means for verified apps, you will need to apply to be approved for the Intent, or you will need to migrate your commands to slash commands or use other solutions (some examples are available here: https://dis.gd/mcalternatives)

This means that bots that have commands starting with ., !, + and so on will stop working.
That's because the bot won't be able to access user messages unless they are approved - and this approval process is really restricted.

Message intent request

However, most developers won't need to request this approval because they can just migrate their message commands to slash commands, which are now supported on discord.js library:

Problem

While they are very easy to implement, we don't many have examples on how to add this feature for typescript projects.

That's still okay because we just have to type some stuff here and there.

The thing is that internal classes from discord.js library are now private or protected, so we can't mock them as we would usually do for an advanced integration testing.

Or can we?

This guide

Here I will show you how I have migrated message commands to slash commands on Corvo Astral and how I've adapted the tests I've made for it.

GitHub logo Markkop / corvo-astral

A Discord Bot that serves as an Wakfu helper

There are probably better and cleaner ways of doing this migration, but I hope this guide gives you enough knowledge to figure out an approach that works best for you.

If you're looking for a guide on how to create a bot from scratch with slash commands instead, check out this awesome Build a 100 Days of Code Discord Bot with TypeScript, MongoDB, and Discord.js 13 guide.

Registering Slash Commands

Your first contact when searching how to migrate to slash commands was probably this official discord.js example guide:

Here they suggest creating a data file for each command and batch reading them to register their name and options.

However, if you follow the guide as itself, you'll have this problem: Property 'commands' does not exist on type 'Client' in Typescript

node:v16.11.0 "discord.js": "^13.2.0"

I am setting up an example Discord.js bot.

In this guide, I am to the point of adding this line:

client.commands = new Collection();

Typescript then complains with: Property 'commands' does not exist on type 'Client<boolean>'

This answer seems to be in the same vein asโ€ฆ

There are some workarounds that might work, but we're using typescript and we don't want to create and extend types just to attach this command list to the client instance.

I found it better to just keep this command data objects within each command file and export them separately. I would then import them all in the commands/index.js file to finally export them as an array.

import AboutCommand, { getData as getAboutData} from './About'
import EquipCommand, { getData as getEquipData } from './Equip'
import CalcCommand, { getData as getCalcData} from './Calc'
import RecipeCommand, { getData as getRecipeData} from './Recipe'
import SubliCommand, { getData as getSubliData } from './Subli'
import AlmaCommand, { getData as getAlmaData} from './Alma'
import HelpCommand, { getData as getHelpData} from './Help'
import ConfigCommand, { getData as getConfigData } from './Config'
import PartyCreateCommand, { getData as getPartyCreateData } from './party/PartyCreate'
import PartyUpdateCommand, { getData as getPartyUpdateData } from './party/PartyUpdate'
import PartyReaction from './party/PartyReaction'

export {
  AboutCommand,
  EquipCommand,
  CalcCommand,
  RecipeCommand,
  SubliCommand,
  AlmaCommand,
  HelpCommand,
  ConfigCommand,
  PartyCreateCommand,
  PartyUpdateCommand,
  PartyReaction
}

export default [
  getAboutData,
  getCalcData,
  getAlmaData,
  getConfigData,
  getPartyCreateData,
  getPartyUpdateData,
  getEquipData,
  getRecipeData,
  getSubliData,
  getHelpData
]
Enter fullscreen mode Exit fullscreen mode

Note: I'm using a getData function instead of a data object because I change its content according to a language parameter. You probably can keep them as objects.

This array would be looped inside a registerCommands function, parsed to JSON and making a put request to the applicationGuildCommands discord route.

export async function registerCommands (
  client: Client, 
  guildId: string, 
  guildConfig: GuildConfig, 
  guildName: string) {
  try {
    const rest = new REST({ version: "9" }).setToken(process.env.DISCORD_BOT_TOKEN);

    const commandData = commandsData.map((getData) => {
      const data = getData(guildConfig.lang)
      return data.toJSON()
    });

    await rest.put(
      Routes.applicationGuildCommands(client.user?.id || "missing id", guildId),
      { body: commandData }
    );

    console.log("Slash commands registered!");
  } catch (error) {
    if (error.rawError?.code === 50001) {
      console.log(`Missing Access on server "${guildName}"`)
      return
    }
    console.log(error)
  } 
};
Enter fullscreen mode Exit fullscreen mode

Again: your commandsData.map callback would be as simple as data => data.toJSON() if you're only using data objects.

Remember that you can check this bot's source code directly to understand it better.

Reusing slash command options

Since we're here, it might be worth to mention that you can - and should - reuse commands options.
What I mean is that you can refactor this:

export const aboutCommandData = new SlashCommandBuilder()
  .setName('about')
  .setDescription('The about command')
  .addStringOption(option => option
    .setName('language')
    .setDescription('The language option'))

export const helpCommandData = new SlashCommandBuilder()
  .setName('help')
  .setDescription('The help command')
  .addStringOption(option => option
    .setName('language')
    .setDescription('The language option'))
Enter fullscreen mode Exit fullscreen mode

To this:

export function addLangStringOption(builder: SlashCommandBuilder|SlashCommandSubcommandBuilder) {
  return builder
      .addStringOption(option => option
        .setName('language')
        .setDescription('The language option'))
}

export const aboutCommandData = (lang: string) => {
  const builder = new SlashCommandBuilder()
  builder
    .setName('about')
    .setDescription('The about command')
  addLangStringOption(builder)
  return builder
}

export const helpCommandData = (lang: string) => {
  const builder = new SlashCommandBuilder()
  builder
    .setName('help')
    .setDescription('The help command')
  addLangStringOption(builder)
  return builder
}
Enter fullscreen mode Exit fullscreen mode

You can extend this approach even further with subcommands and choices, like this:

export function addStringOptionWithLanguageChoices(
  builder: SlashCommandBuilder|SlashCommandSubcommandBuilder, 
  name: string, 
  description: string
) {
  return builder.addStringOption(option => {
    option
      .setName(name)
      .setDescription(description)
      ['pt', 'en', 'fr', 'es'].forEach(language =>
        option.addChoice(language, language)
      )
      return option
    })
}

export function addLangAndTranslateStringOptions(
  builder: SlashCommandBuilder|SlashCommandSubcommandBuilder,
  lang: string
) {
  addLangStringOption(builder, lang)
  addStringOptionWithLanguageChoices(
    builder, 
    'translate',
    stringsLang.translateCommandOptionDescription[lang]
  )
  return builder
}

export function addLangStringOption(
  builder: SlashCommandBuilder|SlashCommandSubcommandBuilder,
  lang: string
) {
  return addStringOptionWithLanguageChoices(
    builder, 
    'lang', 
    stringsLang.langCommandOptionDescription[lang]
  )
}
Enter fullscreen mode Exit fullscreen mode

(check the source code here)

Mocking Discord.js

When I first started this project a few years ago, I was not using Typescript and mocking user messages was simple as this

import config from '../src/config'
const { defaultConfig: { partyChannel } } = config

/**
 * Mocks a channel message to match properties from a Discord Message.
 * Note that channel messages has actually Collection type and here we're treating them
 * as arrays and enriching their properties to have the same as a Discord Collection.
 *
 * @param {string} content
 * @param {object[]} channelMessages
 * @returns {object}
 */
export function mockMessage (content, channelMessages = []) {
  channelMessages.forEach(channelMessages => { channelMessages.edit = jest.fn(message => message) })
  channelMessages.nativeFilter = channelMessages.filter
  channelMessages.filter = (func) => {
    const filtered = channelMessages.nativeFilter(func)
    filtered.first = () => filtered[0]
    filtered.size = filtered.length
    return filtered
  }
  return {
    react: jest.fn(),
    content: content,
    channel: {
      send: jest.fn(message => {
        if (typeof message === 'object') {
          message.react = jest.fn()
        }
        return message
      })
    },
    author: {
      id: 111,
      username: 'Mark'
    },
    guild: {
      id: 100,
      name: 'GuildName',
      channels: {
        cache: [
          {
            name: partyChannel,
            messages: {
              fetch: jest.fn().mockResolvedValue(channelMessages)
            },
            send: jest.fn(message => {
              message.react = jest.fn()
              return message
            })
          }
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Ugly, right?

Well, it was working until I migrated the bot to typescript and a more Object-Oriented Programming structure and got showered with typing errors.

This made me to understand how Discord.js worked and it let me create an entire mocking structure (you'll see it in the next code snippet).

But now with v13, Discord changed their internal classes to be private or protected, so we can't just call new when instancing one of them.

That's what I thought.

Classes' constructor should not be marked as 'private' in typings #6798

Issue description

I was just updated discord.js and then I realize that after updating it to v13.2, it said that the 'Message' class constructor is private. I asked my friend about this and then my friend gave me a PR link that caused this change. After looking at the PR, I'm pretty sure that making the classes' constructor 'private' in typings is actually not needed and should be set as 'public' instead. Sometimes, classes are used for ensuring that the result is what we wanted. For example, there's a possibility that when we send message the result is APIMessage instead (according to the typings). This is where classes like 'Message' is needed.

Code sample

const { Message } = require("discord.js");

// pretend that there's a variable named 'msg' which is a result of sending a message and 'client' which is the client
msg instanceof Message ? msg : new Message(client, msg);
Enter fullscreen mode Exit fullscreen mode

discord.js version

13.2.0

Node.js version

Node.js 16.6.2, Typescript 4.4.3

Operating system

No response

Priority this issue should have

Medium (should be fixed soon)

Which partials do you have configured?

No Partials

Which gateway intents are you subscribing to?

GUILDS, GUILD_EMOJIS_AND_STICKERS, GUILD_VOICE_STATES, GUILD_MESSAGES

I have tested this issue on a development release

No response

Thanks to this issue, I not only found out we could simply use @ts-expect-error comment lines but we could also use Reflect.construct javascript method.

This is really awesome because we don't have to change our mocking structure anymore.

Here's a lean example of this mocking strucure with the Reflect.construct solution.

import {
  Client,
  User,
  CommandInteraction,
} from "discord.js";

export default class MockDiscord {
  private client!: Client;
  private user!: User;
  public interaction!: CommandInteraction;

  constructor(options) {
    this.mockClient();
    this.mockUser();
    this.mockInteracion(options?.command)
  }

  public getInteraction(): CommandInteraction {
    return this.interaction;
  }

  private mockClient(): void {
    this.client = new Client({ intents: [], restSweepInterval: 0 });
    this.client.login = jest.fn(() => Promise.resolve("LOGIN_TOKEN"));
  }

  private mockUser(): void {
    this.user = Reflect.construct(User, [
        this.client, {
          id: "user-id",
          username: "USERNAME",
          discriminator: "user#0000",
          avatar: "user avatar url",
          bot: false,
        }
      ]
    )
  }

  private mockInteracion(command): void {
    this.interaction = Reflect.construct(CommandInteraction, [
      this.client,
        {
          data: command,
          id: BigInt(1),
          user: this.user,
        }
      ]
    )
    this.interaction.reply = jest.fn()
    this.interaction.isCommand = jest.fn(() => true)
  }
}
Enter fullscreen mode Exit fullscreen mode

For a more accurate mock, please refer to my mockDiscord.ts file on the project's repository.

Testing commands

Now that we have our discord.js mock setup, we just need some testing function helpers and the test files themselves.

Our main testing function will be executeCommandAndSpyReply:

import MockDiscord from './mockDiscord'

/* Spy 'reply' */
export function mockInteractionAndSpyReply(command) {
  const discord = new MockDiscord({ command })
  const interaction = discord.getInteraction() as CommandInteraction
  const spy = jest.spyOn(interaction, 'reply') 
  return { interaction, spy }
}

export async function executeCommandAndSpyReply(command, content, config = {}) {
  const { interaction, spy } = mockInteractionAndSpyReply(content)
  const commandInstance = new command(interaction, {...defaultConfig, ...config})
  await commandInstance.execute()
  return spy
}
Enter fullscreen mode Exit fullscreen mode

Since I'm using an OOP approach for this bot, I have to call new command to be able to execute() it, but I'm sure you'll be able to adapt to however you've built your bot.

As you can see, we first initialize a DiscordJS instance, passing a mocked command parameter that will be then used to mock an interaction as well.

That command argument is an object that contains the data of a CommandInteraction.

We could build it ourselves, but I found easier to write a parse function to read a single string command, such as /config set lang: en and transform it to the expected object.

Here is the result

// Usage
it('returns the matching sublimation when using query without accents', async () => {
  const stringCommand = '/subli by-name name: influencia lang: pt'
  const command = getParsedCommand(stringCommand, commandData)
  const spy = await executeCommandAndSpyReply(SubliCommand, command)
  expect(spy).toHaveBeenCalledWith(embedContaining({
    title: ':scroll: Influรชncia I'
  }))
})
Enter fullscreen mode Exit fullscreen mode
// getParsedCommand implementation 

export const optionType = {
  // 0: null,
  // 1: subCommand,
  // 2: subCommandGroup,
  3: String,
  4: Number,
  5: Boolean,
  // 6: user,
  // 7: channel,
  // 8: role,
  // 9: mentionable,
  10: Number,
}

function getNestedOptions(options) {
  return options.reduce((allOptions, option) => {
    if (!option.options) return [...allOptions, option]
    const nestedOptions = getNestedOptions(option.options)
    return [option, ...allOptions, ...nestedOptions]
  }, [])
}

function castToType(value: string, typeId: number) {
  const typeCaster = optionType[typeId]
  return typeCaster ? typeCaster(value) : value
}

export function getParsedCommand(stringCommand: string, commandData) {
  const options = getNestedOptions(commandData.options)
  const optionsIndentifiers = options.map(option => `${option.name}:`)
  const requestedOptions = options.reduce((requestedOptions, option) => {
    const identifier = `${option.name}:`
    if (!stringCommand.includes(identifier)) return requestedOptions
    const remainder = stringCommand.split(identifier)[1]

    const nextOptionIdentifier = remainder.split(' ').find(word => optionsIndentifiers.includes(word))
    if (nextOptionIdentifier) {
      const value = remainder.split(nextOptionIdentifier)[0].trim()
      return [...requestedOptions, {
        name: option.name,
        value: castToType(value, option.type),
        type: option.type
      }]
    }

    return [...requestedOptions, {
      name: option.name,
      value: castToType(remainder.trim(), option.type),
      type: option.type
    }]
  }, [])
  const optionNames = options.map(option => option.name)
  const splittedCommand = stringCommand.split(' ')
  const name = splittedCommand[0].replace('/', '')
  const subcommand = splittedCommand.find(word => optionNames.includes(word))
  return {
    id: name,
    name,
    type: 1,
    options: subcommand ? [{
      name: subcommand,
      type: 1,
      options: requestedOptions
    }] : requestedOptions
  }
}
Enter fullscreen mode Exit fullscreen mode

I've tried several ways to get parse a command and this is the best I've found. It uses the command data that was generated by SlashCommandBuilder (in my case it's a function because I change its language dynamically) to identify which subcommands and options are being used.

Please note that in its current way it only identifies one subcommand, so a refactor would be needed to support multiple subcommands.

If you find this implementation too complex, feel free to pass a command object using its data structure directly:

{
  "id": "config",
  "name": "config",
  "type": 1,
  "options": [
    {
      "type": 1,
      "name": "set",
      "options": [{
        "value": "en",
        "type": 3,
        "name": "lang"
      }],
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Due to the complexity of my own bot, the content I've just presented might be too much for your needs.

But I'm sure that my code examples will help you in some way.

Working on the migration from message commands to slash commands have been an awesome challenge. There's not much on the internet currently about it, so I had to rely mostly on the Discord.js source code itself.

If you're having any trouble with it, feel free to comment here, open an issue on the Corvo Astral repository (even if it's about your own project) or contact me by twitter!

References

Top comments (5)

Collapse
 
lavamoulton profile image
Daniel M

Very helpful, thank you!

For the last step, pulling it all together after building the command parser, can you share what you are passing in commandData and SubliCommand?

Thanks!

Collapse
 
heymarkkop profile image
Mark Kop

Sure! The commandData is the return of a new SlashCommandBuilder() from @discordjs/builders and the SubliCommand is an extended class that is responsible for processing the command and triggering the interaction reply method.

You can check the implementation I'm using by tracking this test file and this command file:

github.com/Markkop/corvo-astral/bl...

github.com/Markkop/corvo-astral/bl...

Collapse
 
opyficarlogg profile image
OpyFicarlogg

Hello,
I have mocked commandInteraction, and I use options in my code (getInteger).

But I can't figure out how to mock the options object (type CommandInteractionOptionResolver), can you help me on this?

Thank you

Collapse
 
heymarkkop profile image
Mark Kop

Hey, there.
You shouldn't need to mock CommandInteractionOptionResolver, because we're passing the resolved options directly to the CommandInteraction class.
That's why, according to this guide, you have to pass it as an object like this

{
  "id": "config",
  "name": "config",
  "type": 1,
  "options": [
    {
      "type": 1,
      "name": "set",
      "options": [{
        "value": "en",
        "type": 3,
        "name": "lang"
      }],
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

or parse a command line like I've showed previously in the guide.

Collapse
 
sharpcollector profile image
Sharp ๐Ÿ”ฅ๐Ÿ’ƒ

Super underrated article! The discord testing is almost an unknow topic even within discord's own development discord.