Skip to main content

Command handling

Command handling is, by definition, a way to handle commands in multiple files. Because by creating a lot of commands, your code in your main file will start to get really big and have a if/else if/switch hell.

Unlike many other languages, Dart does not have a way to dynamically — only known at runtime — import files. But don't worry, there's still a way to handle properly your commands, slash commands and legacy commands. nyxx_commands is a library that can handle commands for you.

Creation of client

Let's setup a basic example, first, we need to create our client.

import 'package:nyxx/nyxx.dart';

void main() {
// nyxx_commands doesn't support INyxxRest yet, so we need to use INyxxWebsocket.
final client = NyxxFactory.createNyxxWebsocket(
'<TOKEN>',
GatewayIntents.allUnprivileged | GatewayIntents.guildMembers,
);
}

Now, by convenience, we register the basic plugins. These aren't required for nyxx_commands to work

import 'package:nyxx/nyxx.dart';

void main() {
// nyxx_commands doesn't support INyxxRest yet, so we need to use INyxxWebsocket.
final client = NyxxFactory.createNyxxWebsocket(
'<TOKEN>',
GatewayIntents.allUnprivileged | GatewayIntents.guildMembers,
);

client
..registerPlugin(Logging())
..registerPlugin(CliIntegration())
..registerPlugin(IgnoreExceptions());
}

Creating our CommandsPlugin

Ok, so now, we have client ready to be used, but we didn't used nyxx_commands after all. So let's get into it.

import 'package:nyxx/nyxx.dart';
import 'package:nyxx_commands/nyxx_commands.dart';

void main() {
// nyxx_commands doesn't support INyxxRest yet, so we need to use INyxxWebsocket.
final client = NyxxFactory.createNyxxWebsocket(
'<TOKEN>',
GatewayIntents.allUnprivileged | GatewayIntents.guildMembers,
);

client
..registerPlugin(Logging())
..registerPlugin(CliIntegration())
..registerPlugin(IgnoreExceptions());

// Next, we need to create our plugin. The plugin class used for nyxx_commands is `CommandsPlugin`
// and we need to store it in a variable to be able to access it for registering commands and
// converters.

CommandsPlugin commands = CommandsPlugin(
// The `prefix` parameter determines what prefix nyxx_commands will use for text commands.
//
// It isn't a simple string but a function that takes a single argument, an `IMessage`, and
// returns a `String` indicating what prefix to use for that message. This allows you to have
// different prefixes for different messages, for example you might want the bot to not require
// a prefix when in direct messages. In that case, you might provide a function like this:
// ```dart
// prefix: (message) {
// if (message.startsWith('!')) {
// return '!';
// } else if (message.guild == null) {
// return '';
// }
// }
//```
//
// In this case, we always return `!` meaning that the prefix for all messages will be `!`.
prefix: (message) => '!',

// The `guild` parameter determines what guild slash commands will be registered to by default.
//
// This is useful for testing since registering slash commands globally can take up to an hour,
// whereas registering commands for a single guild is instantaneous.
//
// If you aren't testing or want your commands to be registered globally, either omit this
// parameter or set it to `null`.
guild: Snowflake(Platform.environment['GUILD']!),

// The `options` parameter allows you to specify additional configuration options for the
// plugin.
//
// Generally you can just omit this parameter and use the defaults, but if you want to allow for
// a specific behaviour you can include this parameter to change the default settings.
//
// In this case, we set the option `logErrors` to `true`, meaning errors that occur in commands
// will be sent to the logger. If you have also added the `Logging` plugin to your client, these
// will then be printed to your console.
// `true` is actually the default for this option and was included here for the sake of example.
options: CommandsOptions(
logErrors: true,
),
);

// Next, we add the commands plugin to our client:
client.registerPlugin(commands);


// Finally, we tell the client to connect to Discord:
client.connect();
}

Registering a command

To register a command, we must first create an instance of the ChatCommand class. This class represents a single command, slash command or not, and once added to the bot will automatically be ready to use.

For our first command, let's create the famous ping command that'll reply with pong.

The first parameter is the command name. Command names must obey certain rules, if they don't an error will be thrown. Generally, using lower case letters and dashes (-) instead of spaces or undersoces will avoid any problems.


The second parameter is the command description. In traditional command frameworks, command descriptions aren't required. However, Discord requires that all slash commands have a description, so it is needed in nyxx_commands.
The third parameter is the function that will be executed when the command is ran.

The first parameter to this function must be a IChatContext.

A IChatContext allows you to access various information about how the command was run: the user that executed it, the guild it was ran in and a few other useful pieces of information. IChatContext also has a couple of methods that make it easier to respond to commands. Since a ping command doesn't have any other arguments, we don't add any other parameters to the function.

// Finally, we tell the client to connect to Discord:
client.connect();

ChatCommand ping = ChatCommand(
'ping',
"Checks if the bot's online",
(IChatContext context) {
// For a ping command, all we need to do is respond with `pong`.
// To do that, we can use the `Context`'s `respond` method which responds to the command with
// a message.
context.respond(MessageBuilder.content('pong!'));
},
);

Adding a command to the CommandsPlugin

Once we've created our command, we need to add it to our bot.

The commands on a bot can be represented with a parent-child tree that looks like this:

client
┗━ ping
ChatCommand ping = ChatCommand(
'ping',
"Checks if the bot's online",
(IChatContext context) {
// For a ping command, all we need to do is respond with `pong`.
// To do that, we can use the `Context`'s `respond` method which responds to the command with
// a message.
context.respond(MessageBuilder.content('pong!'));
},
);

commands.addCommand(ping);

At this point, if you run this file, you should see a slash command named "ping" appear in Discord. Executing it should make the bot send pong! to the channel the command was executed in. You can also send a text message starting with !ping and you should see a similar result.


If you don't want to be mentionned, you can use the mention option in the MessageChatContext's respond Like so:

context.respond('pong!', mention: false);

info

You must check if the context is a MessageChatContext to apply the mention parameter.

Registering a command group

Command groups are a powerful tool that allow you to group commands together. As an example, we'll create a command group throw with two sub-commands: coin and die ( the singular form of dice, not the verb). Our command structure will look like this once we're done (the ping command we made earlier is still there):

client
┗━ ping
┗━ throw
┗━ coin
┗━ die

Ok, so, let's create our first command group.

Similarly to ChatCommand, the ChatGroup constructor's first two arguments are the group's name and description.

However, there is no execute parameter; groups are not commands, and as such cannot be run, so it makes no sense to have a function to execute. children is an optional parameter that allows you to specify what commands are part of this group. It isn't the only way to add commands to a group though, so we'll add the throw coin method here and the throw die command later.

// We have to use the variable name `throwGroup` since `throw` is a reserved keyword. Note that
// the variable name does not change how the group behaves at all though.
ChatGroup throwGroup = ChatGroup(
'throw',
'Throw an object',
children: [
// We create a command in the same way as we created the `ping` command earlier.
ChatCommand(
'coin',
'Throw a coin',
(IChatContext context) {
bool heads = Random().nextBool();

context.respond(
MessageBuilder.content('The coin landed on its ${heads ? 'head' : 'tail'}!'),
);
},
)
],
);

There's another way to add a command to a group. It's by using the ChatGroup's addCommand method.

Like so:

throwGroup.addCommand(
ChatCommand(
'die',
'Throw a die',
(IChatContext context) {
int number = Random().nexInt(6) + 1;

context.respond(MessageBuilder.content('The die landed on the $number!'));
}
),
);

// Finally, just like the `ping` command, we need to add our command group to the bot:
commands.addCommand(throwGroup);

At this point, if you run this file, a new command should have appeared in the slash command menu on Discord: throw. Selecting it will let you choose from two sub-commands: coin or die. Executing either of them should result in the bot sending a message with a random outcome!

You can also send a text message starting with !throw coin or !throw die to execute the commands.

Using command arguments

Command argumens are another powerful tool that allow you to get user input when using commands. Adding arguments to your commands in nyxx_commands is simple, just add the argument as a parameter to your execute function and nyxx_commands will do the rest, including:

  • Converting the parameter name to a Discord-friendly name;
  • Converting user input to the correct type;
  • Inferring the best Discord slash command argument type to use for that argument

As an example, let's implement a say command that simply repeats what the user input.

ChatCommand say = ChatCommand(
'say',
'Make the bot say something',

// As mentioned earlier, all we need to do to add an argument to our command is add it as a
// parameter to our execute function. In this case, we take an argument called `message` and of
// type `String`.
(Context context, String message) {
context.respond(MessageBuilder.content(message));
},
);

// As usual, we need to register the command to our bot.
commands.addCommand(say);

At this point, if you run this file your command structure will look like this:

client
┗━ ping
┗━ throw
┗━ coin
┗━ die
┗━ say

A new command will also have appeared in Discord: say. Notice that this new command has an argument, and Discord won't let you execute it unless your provide a value for that argument.

As usual, you can also execute the command with a message starting with !say. However, if you try to run the command with a message that just says !say, you will notice an error is thrown in your console. This is because you didn't provide a value for the message argument. Sending !say hi will result in the bot sending a message hi as expected.

Warning

Sending !say hello, nyxx_commands! will only make the bot send a message with hello,. This is because the string parser assumes that one word = one argument. To avoid this, put quotes around your argument to tell nyxx_commands that until the next quote, all characters should be treated as a single argument (escaping quotes is supported). Running !say "hello, nyxx_commands!" results in hello, nyxx_commands! being sent as expected.

Using converters

Converters allow you to convert user input to specific Dart types. In the previous example, no conversion was needed as input received from Discord is already a String. However, the input received from Discord might not always be the type you need it to be. To convert the input from Discord to the type needed for your execute function, the Converter class is used.

nyxx_commands registers a few converters by default for commonly used types such as ints, doubles, IMembers and others. We'll look into creating custom converters later, and for now just use the built-in converters. Using converters is just as simple as using arguments: simply specify the argument name and type, and nyxx_commands will determine which Converter to use for that argument.

As an example, let's create a nick command that changes a user's nickname.

ChatCommand nick = ChatCommand(
'nick',
"Change a user's nickname",
// Setting the type of the `target` parameter to `IMember` will make nyxx_commands convert user
// input to instances of `IMember`.
(IChatContext context, IMember target, String newNick) async {
try {
await target.edit(nick: newNick);
} on IHttpResponseError {
context.respond(MessageBuilder.content("Couldn't change nickname :/"));
return;
}
context.respond(MessageBuilder.content('Successfully changed nickname!'));
},
);

commands.addCommand(nick);

At this point, if you run the file your command structure will look like this:

client
┗━ ping
┗━ throw
┗━ coin
┗━ die
┗━ say
┗━ nick

A new nick command will have been added to Discord's slash command menu. Selecting it will prompt you for both a target and a new nickname. Notice that the target argument will only allow you to select members of the guild the command was ran in: nyxx_commands automatically selected an appropriate slash command argument type for the target parameter.

Also notice that the newNick argument name was changed to new-nick. nyxx_commands will automatically convert your camelCase dart identifiers to Discord-compatible kebab-case argument names, meaning you can keep your code clean without issues.

You can also run the command from a text message, with !nick *target* *new-nick*. Unlike slash commands, there is no way to filter user input before it gets to our bot, so we might end up with an invalid input. If that is the case, the converter for IMember will be unable to convert the user input to a valid member, and the command will fail with an exception.

info

Note that the bot must have the MANAGE_MEMBERS permission and have a higher role than the target user for this command to succeed.

Using custom converters

Custom converters allow you to specify a custom way of converting user input to a specific type that you can then use in your commands. You can define a custom converter in a few ways:

  • By creating your own converter from scratch;
  • By extending an existing converter;
  • By combining existing converters

As an example, we will create converters for two enums defined at the bottom of this file: Shape and Dimension.

Using fully custom converters

To start off, we will create a converter for Shapes. We will need to create a brand new converter from scratch for this, since no existing converter can be mapped to a Shape.

enum Shape {
triangle,
square,
pentagon,
}


// Note that the variable is fully typed, the typed generics on `Converter` are filled in.
// This allows nyxx_commands to know what the target type of this converter is.
Converter<Shape> shapeConverter = Converter<Shape>(
// The first parameter to the `Converter` constructor is a function.
// This function is what actually does the converting, and can return two things:
// - An instance of the target type (in this case, `Shape`), indicating success;
// - `null`, indicating failure (input cannot be parsed)
//
// The first parameter to the function is an instance of `StringView`. `StringView` allows you
// to manipulate and extract data from a `String`, but also allows the next converter to know
// where to start parsing its argument from.
// The second parameter is the current `Context` in which the argument is being parsed.
(view, context) {
// In our case, we want to return a `Shape` based on the user's input. The `getQuotedWord()`
// will get the next quoted word from the input.
// Note that although `getWord()` exists, you shouldn't use it unless you have to as it can
// mess up argument parsing later down the argument parsing.
switch (view.getQuotedWord().toLowerCase()) {
case 'triangle':
return Shape.triangle;
case 'square':
return Shape.square;
case 'pentagon':
return Shape.pentagon;
default:
// The input didn't match anything we know, so we assume the input is invalid and return
// `null` to indicate failure.
return null;
}
},
// The second parameter to the `Converter` constructor is a list (or any iterable) of
// nyxx_interaction's `ArgChoiceBuilder`, allowing you to specify the choices that will be shown
// to the user when running this command from a slash command.
choices: [
ArgChoiceBuilder('Triangle', 'triangle'),
ArgChoiceBuilder('Square', 'square'),
ArgChoiceBuilder('Pentagon', 'pentagon'),
],
);


// Once we've created our converter, we need to add it to the bot:
commands.addConverter(shapeConverter);

Using assembled converters

For our Dimension converter, we can extend an existing converter: all Dimensions are integers, so we can just extend the already existing intConverter and transform the integer to a Dimension.

enum Dimension {
twoD,
threeD,
}

// To extend an existing `Converter`, we can use the `CombineConverter` class. This takes an
// exising converter and a function to transform the output of the original converter to the
// target type.
// Similarly to the shape converter, this variable has to be fully typed. The first type argument
// for `CombineConverter` is the target type of the inital `Converter`, and the second is the
// target type of the `CombineConverter`.
Converter<Dimension> dimensionConverter = CombineConverter<int, Dimension>(
intConverter,
// Also like `Converter`, the second parameter to the transform function is the current context.
(number, context) {
switch (number) {
case 2:
return Dimension.twoD;
case 3:
return Dimension.threeD;
default:
return null;
}
},
);

At this point, if you run the file you will see that the favourite-shape command has been added to the slash command menu. Selecting this command, you will be prompted to select a shape from the choices we outlined earlier and a dimension. Note that in this case Discord isn't able to give us choices since we haven't told it what dimensions are availible.

If you run the command, you will see that your input will automatically be converted to a Shape and Dimension by using the converters we defined earlier. Similarly to when we created the nick command, if either of our converters fail the command will fail with an error.

Using optional arguments

Optional argument allow you to allow the user to input something while providing a default value. Like converters, optional arguments are easy to use; just make the parameter in your execute function optional.

You must use optional parameters and not named parameters in the execute function as the order matters when using text commands. Note that when using slash commands, users can specify optional arguments in any order and can omit certain optional arguments. nyxx_commands will fix the ordering of the arguments, however you are not guaranteed to receive all optional arguments up to the last one positionally.

As an example:

(IChatContext context, [String? a, String? b, String? c]) {}

In this case, b having a value does not guarantee a has a value. As such, it is always better to provide a default for your optional parameters instead of making them nullable.

// As an example for using optional arguments, let's create a command with an optional argument:
ChatCommand favouriteFruit = ChatCommand(
'favourite-fruit',
'Outputs your favourite fruit',
(IChatContext context, [String favourite = 'apple']) {
context.respond(MessageBuilder.content('Your favourite fruit is $favourite!'));
},
);

commands.addCommand(favouriteFruit);

At this point, if you run the file you will be able to use the favourite-fruit command. Once you've selected the command in the slash command menu, you'll be given an option to provide a value for the favourite argument. If you don't specify a value for the argument, the default value of 'apple' will be used. If you do specify a value, that value will be used instead.

Using optional arguments in text commands works as expected: arguments are parsed from the user input until there is no text left to parse or all possible arguments. This means that if you want to specify an optional argument, you just have to type it in as you would with a non-optional argument and the arguments will be filled in left to right in the order they were declared.

Using checks

Command checks allow you to restrict a command's usage. There are a few built-in checks that integrate with Discord's slash command permissions, and a special cooldown check.

As an example, we'll create a command with a cooldown:

ChatCommand alphabet = ChatCommand(
'alphabet',
'Outputs the alphabet',
(IChatContext context) {
context.respond(MessageBuilder.content('ABCDEFGHIJKLMNOPQRSTUVWXYZ'));
},
// Since this command is spammy, we can use a cooldown to restrict it's usage:
checks: [
CooldownCheck(
CooldownType.user,
Duration(seconds: 30),
)
],
);

commands.addCommand(alphabet);

Using converters override

Converter overrides allow you to override which Converter is used to convert a specific argument. They can be used, for example, to filter the output of a converter so that only certain instances are ever passed to the function. You might have noticed that if we run the command !say "", the bot will throw an error because it cannot send an empty message. This is because the string Converter saw "" and interpreted it as an empty string - which the bot then tried to send.

To counter this, let's create a converter that only allows non-empty strings:

// Since the converter is going to be passed into a decorator, it must be `const`. As such, any
// functions used must either be static methods or top-level functions.
// You can see the implementation of `filterInput` at the bottom of this file.
const Converter<String> nonEmptyStringConverter = CombineConverter(stringConverter, filterInput);

ChatCommand betterSay = ChatCommand(
'better-say',
'A better version of the say command',
(
IChatContext context,
(nonEmptyStringConverter) String input,
) {
context.respond(MessageBuilder.content(input));
},
);

commands.addCommand(betterSay);

String? filterInput(String input, Context context) {
if (input.isNotEmpty) {
return input;
}
return null;
}

At this point, if you run the file, a new command better-say will have been added to the bot. Attempting to invoke it with an empty string (!better-say "") will cause the argument to fail parsing.

That's all! Well done, you've reached the end of this guide. You're now able to go and create your own bot on your side. Remember; You just need to create a function or a class in a specific file, importing it, and register the function.