Skip to content

Slash commands

Bostie writing a slash command in a speech bubble

A slash command is an async function decorated with @bot.command. Its first parameter takes the Context; every parameter after it is an option of the command.

from typing import Annotated

import bostie
from bostie import Attachment, Context, PartialChannel, ResolvedMember, Role

bot = bostie.Bot()


@bot.command
async def report(
    ctx: Context,
    member: Annotated[ResolvedMember, "Who to report"],
    reason: Annotated[str, "What happened"],
    channel: PartialChannel | None = None,
    proof: Attachment | None = None,
    notify: Role | None = None,
    urgent: bool = False,
) -> None:
    """Report a member to the moderators."""
    lines = [f"{member.mention}: {reason}"]
    if channel is not None:
        lines.append(f"In {channel.mention}")
    if proof is not None:
        lines.append(f"Proof: {proof.url}")
    if notify is not None:
        lines.append(f"Notify {notify.mention}")
    if urgent:
        lines.append("Urgent")
    await ctx.respond("\n".join(lines), ephemeral=True)

The command is /report. Its name is the function's name and its description is the first line of the docstring; @bot.command(name=..., description=...) sets them instead, and a command with neither gets … as its description. @bot.command and @bot.command(...) both work, on the bot or on a router.

Option types

The type hint of a parameter picks the option type, and the handler receives the value already converted:

Hint Discord option The handler receives
str string the text
int integer an int
float number a float, also when the user typed a whole number
bool boolean a bool
User user the User
ResolvedMember user the member, with its user; only in guilds
Role role the Role
PartialChannel channel the channel as the interaction resolved it
Attachment attachment the uploaded file's Attachment
User \| Role or ResolvedMember \| Role mentionable whichever the user picked
Literal[...] or an Enum string, integer or number with choices the literal or the enum member

A hint the table does not list raises CommandDefinitionError when the decorator runs. For a channel option the hint is PartialChannel, not Channel, and for a member it is ResolvedMember, not Member: an interaction carries those partial objects, and the error message says so.

Required and optional

An option without a default is required. A default, or | None in the hint, makes it optional; an optional option that the user left out gets its default, or None. Discord wants required options first, and bostie sorts them that way when it builds the command, whatever order the parameters have.

Descriptions

A string in Annotated is the option's description: Annotated[str, "What happened"]. The docstring can hold the descriptions instead:

import bostie
from bostie import Context, Range

bot = bostie.Bot()


@bot.command
async def remind(ctx: Context, minutes: Range[int, 1, 1440], text: str) -> None:
    """Set a reminder.

    Args:
        minutes: How long to wait, up to a day.
        text: What to remind you of.
    """
    await ctx.respond(f"In {minutes} minutes: {text}", ephemeral=True)

bostie reads an Args: section, as above, and a Parameters section underlined with dashes; a description may run over several lines. A string in Annotated wins over Option(description=), which wins over the docstring. An option described nowhere gets ….

Ranges and option settings

Range[int, 1, 10] is an int option from 1 to 10, inclusive. Range also takes float, and for str the bounds are the length: Range[str, 1, 2000]. Option(...) in Annotated sets everything else, and it wins over what the hint implies:

from typing import Annotated

import bostie
from bostie import Attachment, ChannelType, Context, Option, PartialChannel, Range

bot = bostie.Bot()


@bot.command
async def announce(
    ctx: Context,
    text: Range[str, 1, 2000],
    target: Annotated[
        PartialChannel,
        Option(
            name="channel",
            description="Where to post it",
            channel_types=[ChannelType.GUILD_TEXT, ChannelType.GUILD_ANNOUNCEMENT],
            description_localizations={"de": "Wo es erscheinen soll"},
        ),
    ],
    delay: Annotated[float, Option(min=0, max=3600, description="Seconds to wait")] = 0,
    image: Annotated[Attachment | None, Option(file_types=["image"])] = None,
) -> None:
    """Post an announcement."""
    extra = f" with {image.filename}" if image is not None else ""
    await ctx.respond(f"Posting in {target.mention} in {delay:g} s{extra}: {text}")
Option field Applies to Meaning
name all the option's name on Discord; the parameter keeps its Python name
description all the description, when Annotated holds no string
name_localizations, description_localizations all text per locale, such as {"fr": "salon"}
min, max integer, number inclusive bounds, within ±2⁵³
min_length, max_length string length bounds, 0 to 6000 and 1 to 6000
channel_types channel the channel types the user may pick
file_types attachment up to 10 of image, video, audio or extensions such as .pdf
choices string, integer, number fixed choices; see below
autocomplete string, integer, number a function that suggests values as the user types
extra all raw fields merged into the option's JSON, for a field bostie does not model yet

A field on an option type it does not apply to raises CommandDefinitionError.

Choices

A Literal or an Enum offers a fixed list. A Literal choice is named after its value; an Enum choice is named after the member and sends the member's value. Option(choices=[...]) renames or localizes them, as long as it lists the same values in the same order:

from enum import Enum
from typing import Annotated, Literal

import bostie
from bostie import Choice, Context, Option

bot = bostie.Bot()


class Size(Enum):
    SMALL = 1
    MEDIUM = 2
    LARGE = 3


@bot.command
async def order(
    ctx: Context,
    drink: Literal["tea", "coffee", "water"],
    size: Size = Size.MEDIUM,
    milk: Annotated[
        Literal["none", "oat", "cow"],
        Option(
            choices=[
                Choice("No milk", "none"),
                Choice("Oat milk", "oat", name_localizations={"fr": "Lait d'avoine"}),
                Choice("Cow milk", "cow"),
            ]
        ),
    ] = "none",
) -> None:
    """Order a drink."""
    await ctx.respond(f"One {size.name.lower()} {drink}, milk: {milk}.")

The handler of /order gets "tea", "coffee" or "water" for drink, a Size member for size, and the value of the picked Choice for milk. With a plain str, int or float hint, Option(choices=[...]) alone defines the choices; each is a Choice(name, value) or a bare value, named after itself. Choice values must have the option's type, and the values of a Literal or an Enum must be all str, all int or all float. An option has at most 25 choices, a choice name is 1 to 100 characters and a string value at most 100.

Autocomplete

Option(autocomplete=function) asks function for suggestions while the user types. It gets an AutocompleteContext and the text typed so far, and returns values or Choice objects:

from typing import Annotated

import bostie
from bostie import AutocompleteContext, Choice, Context, Option

bot = bostie.Bot()

CITIES = {
    "fr": ["Paris", "Lyon", "Marseille"],
    "jp": ["Tokyo", "Osaka", "Kyoto"],
}


async def countries(ctx: AutocompleteContext, typed: str) -> list[Choice]:
    return [
        Choice(code.upper(), code) for code in CITIES if code.startswith(typed.lower())
    ]


async def cities(ctx: AutocompleteContext, typed: str) -> list[str]:
    country = ctx.options.get("country", "")
    return [city for city in CITIES.get(country, []) if typed.lower() in city.lower()]


@bot.command
async def weather(
    ctx: Context,
    country: Annotated[str, Option(autocomplete=countries)],
    city: Annotated[str, Option(autocomplete=cities)],
) -> None:
    """Show the weather of a city."""
    await ctx.respond(f"Sunny in {city}, {country.upper()}.")

ctx.options holds the options filled in so far, by option name, converted as the handler would get them when they convert; the option being typed stays as text. bostie sends the first 25 suggestions, cuts names longer than 100 characters, and drops suggestions with an empty name, a value of the wrong type or a string value longer than 100 characters, with a warning for each case. An option cannot have both choices and autocomplete. If the function raises, the error goes to the error hooks and Discord gets an empty list.

Converters

Annotated[T, Convert(function)] turns the option's value into a T before the handler runs. function(ctx, value) may be async, and the type hint of its value parameter decides the Discord option type, Range and Annotated descriptions included:

from dataclasses import dataclass
from typing import Annotated

import bostie
from bostie import Context, Convert, Range

bot = bostie.Bot()


@dataclass(frozen=True)
class Color:
    red: int
    green: int
    blue: int


def parse_color(ctx: Context, text: Range[str, 7, 7]) -> Color:
    value = int(text.removeprefix("#"), 16)
    return Color(value >> 16, value >> 8 & 0xFF, value & 0xFF)


@bot.command
async def paint(
    ctx: Context,
    color: Annotated[Color, Convert(parse_color), "A hex color such as #5865F2"],
) -> None:
    """Paint with a color."""
    await ctx.respond(f"Red {color.red}, green {color.green}, blue {color.blue}.")

The converter runs only for an option the user gave; an optional option left out gets its default unconverted. An exception from the converter goes to the error hooks like an exception from the handler.

Subcommands and groups

bot.group(name, description) registers a command made of subcommands, and group.group(...) adds a group of subcommands inside it. Groups nest one level deep, and a group holds at most 25 entries:

import bostie
from bostie import Context, Permissions, ResolvedMember, Role

bot = bostie.Bot()

roles = bot.group("roles", "Manage roles", default_permissions=Permissions.MANAGE_ROLES)
colors = roles.group("color", "Color roles")


@roles.command
async def give(ctx: Context, member: ResolvedMember, role: Role) -> None:
    """Give a member a role."""
    await ctx.respond(f"/{ctx.command_name}: {member.mention} gets {role.mention}.")


@colors.command(name="set")
async def set_color(ctx: Context, member: ResolvedMember, hex_code: str) -> None:
    """Give a member a color role."""
    await ctx.respond(f"/{ctx.command_name}: {member.mention} is now {hex_code}.")

This defines /roles give and /roles color set. @group.command(name=...) names a subcommand differently from its function. ctx.command_name is the full name that ran, such as "roles color set", and command.qualified_name is the same for the command object.

A top-level group takes the settings of a command except name, ephemeral and auto_defer, and they apply to all its subcommands: default_permissions, nsfw, guilds, contexts and integration_types exist only on top-level commands in Discord. A nested group takes the two localizations, checks and extra. A subcommand takes name, description, the two localizations, ephemeral, auto_defer, checks and extra. The checks of a group run before those of its subcommands.

Command settings

import bostie
from bostie import (
    ApplicationIntegrationType,
    Context,
    InteractionContextType,
    Permissions,
)

bot = bostie.Bot()


@bot.command(
    name="purge",
    description="Delete recent messages",
    name_localizations={"es-ES": "purgar"},
    description_localizations={"es-ES": "Borra mensajes recientes"},
    default_permissions=Permissions.MANAGE_MESSAGES,
    contexts=[InteractionContextType.GUILD],
    integration_types=[ApplicationIntegrationType.GUILD_INSTALL],
    ephemeral=True,
    auto_defer=1.0,
)
async def purge_messages(ctx: Context, count: int = 10) -> None:
    await ctx.respond(f"Deleting {count} messages.")


@bot.command(guilds=[1335172792320131073], nsfw=True)
async def staff(ctx: Context) -> None:
    """Registered in one guild only, and shown in age-restricted channels only."""
    await ctx.respond("Staff only.")
Setting Meaning
name, description the name and description, instead of the function's
name_localizations, description_localizations text per locale; see Registering commands for a translation function
ephemeral answers are visible only to the user, unless a call says otherwise
auto_defer seconds after which an unanswered use is deferred; None turns it off (Answering interactions)
default_permissions the permissions a member needs to see and use the command; server admins can change who may use it
nsfw shown only in age-restricted channels
guilds guild ids to register the command in instead of globally
contexts where a global command can be used: guilds, the bot's DMs, private channels
integration_types which installs carry the command: guild install, user install
checks checks that run before the handler (Checks)
extra raw fields merged into the command's JSON

ephemeral, auto_defer, default_permissions, nsfw, contexts and integration_types can also be set for every command of a router, and contexts, integration_types and auto_defer for every command of the bot.

Limits

The decorator checks what Discord would reject and raises CommandDefinitionError naming the function and the parameter:

  • Names are 1 to 32 characters of letters, digits, - and _, with no uppercase letter. User and message command names may also hold uppercase letters and spaces.
  • Descriptions are 1 to 100 characters.
  • A command has at most 25 options and an option at most 25 choices.
  • *args, **kwargs, positional-only options and options without a type hint are refused.

A warning, not an error, says when a command's names, descriptions and choice values add up to more than the 8000 characters Discord allows. The sync warns when one scope holds more than 100 slash commands, 15 user commands or 15 message commands.

Next