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.
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.
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
]
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)
}
};
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'))
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
}
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]
)
}
(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
})
}
]
}
}
}
}
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);
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)
}
}
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
}
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'
}))
})
// 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
}
}
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"
}],
}
]
}
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!
Top comments (5)
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!
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...
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
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
or parse a command line like I've showed previously in the guide.
Super underrated article! The discord testing is almost an unknow topic even within discord's own development discord.