Anatomy of a command

This page explains how application commands work in Wumpy and how they have been designed.

Implicit information

The library takes advantage of runtime introspection to be able to implicitly extract information from the command callback. Take the following /hello command as an example:

@app.command()
async def hello(interaction: CommandInteraction) -> None:
    """Your first command; hello world."""
    await interaction.respond('...world')

First, the decorator @app.command() placed on the callback causes Wumpy to turn it into a command and register it. In doing so, it implicitly reads the name hello and docstring """Your first command; hello world.""" which get set as command's name and description. If the docstring spans multiple lines, only the first line (called the summary) is used for the command's description.

Additionally, the library also reads the callback's parameter list for the command options. Take a look at the following more complex example from the command options tutorial:

@app.command()
async def echo(
        interaction: CommandInteraction,
        text: str = Option(description='Text to repeat'),
        signature: str = Option('', description='Footer signature')
) -> None:
    """Have the bot echo back the specified text."""
    if signature:
        text += f'\n\n---\n{signature}'

    await interaction.respond(text)

The library reads the parameter list and understands that there are two options named text and signature. The callback's defaults contain the option instances used and the annotations of str means that the option type is set to string.

Subcommands and subcommand groups

Discord allows nesting of commands in subcommands and subcommand groups. In combination you can nest subcommands up to 3 levels deep, see the following examples of valid combinations:

command           command
|                 |
|__ subcommand    |_ subcommand-group
|                    |
|__ subcommand       |_ subcommand

You can of course also nest subcommand next to subcommand groups as follows:

command
|
|__ subcommand-group
    |
    |__ subcommand
|
|__ subcommand

Commands and subcommand groups that hold subcommands cannot be called on their own, that's why these types of commands are created differently - using the .group() method. Compared to .command(), .group() is not a decorator:

greet = app.group('greet', 'Group of greeting commands')

The library fluently uses either a (sub)command or group for the top-level command, and makes no distinction between the two. This means that a double-nested subcommand is represented as subcommand-group -> subcommand-group -> subcommand (compared to Discord's official command -> subcommand-group -> subcommand.

History of library representation

Previously the library had 3 classes matching Discord's: Command, SubcommandGroup, Subcommand. However, the implementation became messy because Command was forced to handle its callback being None. To reduce duplication of code, the implementation of Command was actually a subclass of both SubcommandGroup and Subcommand which meant that it was instead Subcommand which had to handle the missing callback.

The library is designed to be statically typed, to aid with development and reduce unexpected or complicated behaviour. Having a lot of dynamicness leads to code which needs to be taught rather than simply read. This meant that the dynamicness of having the callback be missing was unfavourable and considered janky.

When redesigned to the current system, this was adjusted to remove the forced top-level command. There is now only two representations: SubcommandGroup, and Command. SubcommandGroup is mostly unchanged apart from adding a .group() method, since it now used for both levels of nesting. As a side-effect of this, there is nothing stopping the user from nesting commands further than allowed by Discord - except for Discord itself which will raise an error about invalid configuration if the user attempts this.

Command option APIs

Wumpy has support for specifying options in a variety of ways and aims to be as flexible as possible. This means there are several ways to specify the information that Discord needs.

To get started, take a look at this example of an /echo command:

@app.command()
async def echo(
        interaction: CommandInteraction,
        text: str = Option(description='Text to repeat')
) -> None:
    """Have the bot echo back the specified text."""
    await interaction.respond(text)

Here the library will read the option name from the parameter - which in this case is text - then continue analysing the annotion and default. From this, it understands that the /echo command should have one option named text with the description 'Text to repeat' which takes in a string from the user.

If you do not want to use type annotations you can instead pass type= to the Option() default.

Alternate API

The second API available to specify options uses decorators instead of a special parameter default:

@option('text', description='Text to repeat')
@app.command()
async def echo(interaction: CommandInteraction, text: str) -> None:
    """Have the bot echo back the specified text."""
    await interaction.respond(text)

The name of the parameter is passed to the decorator, followed by the fields to specify. You cannot create new options this way, each option needs a parameter in the callback function.

This API is not recommended because the metadata from each option is moved away from its definition and is not as extensible. You are free to use a combination of these APIs as you wish, but remember to be consistent within your codebase!

Option types

Wumpy supports a variety of annotations representing different option types:

  • str
  • int
  • bool
  • float

  • InteractionChannel

  • User
  • InteractionMember

Special handling is done for User and InteractionMember. If a command annotated with InteractionMember does not receive member information it will not be called. This means that is is recommended to use Union[User, InteractionMember] which causes the library to attempt to lookup member information and fallback to a User - the command will always be called.

On top of this, the library handles more advanced typhints including Annotated, certain Unions (such as Optional and Union[User, InteractionMember]), as well as Literal and Enum subclasses for option choices.

Optional and Required Options

Options can also be marked optional if they are not required. This is done by specifying an option default. This default is only stored locally and will be passed by the library when an optional option wasn't passed by the user.

Because the parameter default is already used by the library to specify option metadata, the default is passed as the first positional argument. Here the default is an empty string '' for the signature parameter:

@app.command()
async def echo(
        interaction: CommandInteraction,
        text: str = Option(description='Text to repeat'),
        signature: str = Option('', description='Footer signature')
) -> None:
    """Have the bot echo back the specified text."""
    if signature:
        text += f'\n\n---\n{signature}'

    await interaction.respond(text)

If you are using the alternate decorator-based API, the actual parameter is free to use. Therefore the default can just be specified by using the actual parameter default. Alternatively, you can pass default= to the @option() decorator.

@option('text', description='Text to repeat')
@option('signature', description='Footer signature')
@app.command()
async def echo(
        interaction: CommandInteraction,
        text: str,
        signature: str = ''
) -> None:
    """Have the bot echo back the specified text."""
    if signature:
        text += f'\n\n---\n{signature}'

    await interaction.respond(text)

You can also have defaults of a different type than the option. For example, the library special-cases Optional from typing and understands that the default must be None:

@app.command()
async def echo(
        interaction: CommandInteraction,
        text: str = Option(description='Text to repeat'),
        signature: Optional[str] = Option(description='Footer signature')
) -> None:
    """Have the bot echo back the specified text."""
    if signature is not None:
        text += f'\n\n---\n{signature}'

    await interaction.respond(text)

Number bounds

The Discord API allows specifying a minimum and maximum value for integers and floats. This is done by specifying a min= and max= to the Option() parameter default or @option() default:

# The name of the command needs to be specified because if the function
# name was hex() it would override the built-in and cause recursion.
@app.command(name='hex')
async def hex_cmd(
        interaction: CommandInteraction,
        num: int = Option(min=0, max=255, description='Number to convert')
) -> None:
    """Convert a number to its hex representation."""
    await interaction.respond(hex(num))
# The name of the command needs to be specified because if the function
# name was hex() it would override the built-in and cause recursion.
@option('num', min=0, max=255, description='Number to convert')
@app.command(name='hex')
async def hex_cmd(interaction: CommandInteraction, num: int) -> None:
    """Convert a number to its hex representation."""
    await interaction.respond(hex(num))

Defining Option Choices

Wumpy has several ways to actually define which choices the user has. The most basic way is to pass choices=... to the Option() parameter default or @option() decorator:

@app.command()
async def rps(
        interaction: CommandInteraction,
        play: str = Option(
            description='The play to make against the bot',
            choices=['rock', 'paper', 'scissors']
        ),
) -> None:
    """Play rock, paper, scissors against the bot."""
    pass

When your command callback is called, the parameter will be one of the passed values.

The choices can also be defined as a dictionary with the key being the name of the choice presented to the user and the value being the value passed to the command callback:

@app.command()
async def rps(
        interaction: CommandInteraction,
        play: str = Option(
            description='The play to make against the bot',
            choices={'rock': 'r', 'paper': 'p', 'scissors': 's']
        ),
) -> None:
    """Play rock, paper, scissors against the bot."""
    pass

In this example, when the user selects the 'paper' choice while invoking the command, the command callback's play parameter will be p.

Choices in Annotations

Since the library reads the parameter annotations, it is also possible to use the Literal annotation for this. This works similar to passing a list to the choices= keyword argument:

@app.command()
async def rps(
        interaction: CommandInteraction,
        play: Literal['rock', 'paper', 'scissors'] = Option(
            description='The play to make against the bot',
        ),
) -> None:
    """Play rock, paper, scissors against the bot."""
    pass

Representing Choices with Enums

Wumpy can also read an Enum as a set of choices if you prefer working with that as opposed to strings and literals. To utilize this feature, start by creating an Enum subclass with the choices the user should have:

from enum import Enum


class RockPaperScissors(Enum):
    rock = 'rock'
    paper = 'paper'
    scissors = 'scissors'

The name of the enum member is what will be shown to the user. You can place much more complex types in the enum value than strings, integers and floats as these are not sent to Discord.

You can now annotate the option with this Enum subclass and Wumpy will read the members as choices for the user:

from enum import Enum


class RockPaperScissors(Enum):
    Rock = 'rock'
    Paper = 'paper'
    Scissors = 'scissors'


@app.command()
async def rps(
        interaction: CommandInteraction,
        play: RockPaperScissors = Option(
            description='The play to make against the bot',
        ),
) -> None:
    """Play rock, paper, scissors against the bot."""
    pass

When the command is dispatched the command callback will receive the corresponding Enum instance of the choice the user made.