Aller au contenu

Commandes slash

Bostie écrivant une commande slash dans une bulle

Une commande slash est une fonction async décorée avec @bot.command. Son premier paramètre reçoit le Context ; chaque paramètre suivant est une option de la commande.

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)

La commande est /report. Son nom est celui de la fonction et sa description est la première ligne de la docstring ; @bot.command(name=..., description=...) les définit à la place, et une commande qui n'a ni l'un ni l'autre reçoit … comme description. @bot.command et @bot.command(...) fonctionnent tous les deux, sur le bot comme sur un routeur.

Types d'options

L'annotation de type d'un paramètre choisit le type de l'option, et le gestionnaire reçoit la valeur déjà convertie :

Annotation Option Discord Le gestionnaire reçoit
str chaîne le texte
int entier un int
float nombre un float, même quand l'utilisateur a tapé un nombre entier
bool booléen un bool
User utilisateur le User
ResolvedMember utilisateur le membre, avec son user ; uniquement sur un serveur
Role rôle le Role
PartialChannel salon le salon tel que l'interaction l'a résolu
Attachment pièce jointe l'Attachment du fichier envoyé
User \| Role ou ResolvedMember \| Role mentionnable celui que l'utilisateur a choisi
Literal[...] ou un Enum chaîne, entier ou nombre avec des choix le littéral ou le membre de l'enum

Une annotation absente du tableau lève CommandDefinitionError à l'exécution du décorateur. Pour une option de salon, l'annotation est PartialChannel, pas Channel, et pour un membre, c'est ResolvedMember, pas Member : une interaction transporte ces objets partiels, et le message d'erreur le précise.

Obligatoires et facultatives

Une option sans valeur par défaut est obligatoire. Une valeur par défaut, ou | None dans l'annotation, la rend facultative ; une option facultative que l'utilisateur a omise reçoit sa valeur par défaut, ou None. Discord veut les options obligatoires en premier, et bostie les trie ainsi quand il construit la commande, quel que soit l'ordre des paramètres.

Descriptions

Une chaîne dans Annotated est la description de l'option : Annotated[str, "What happened"]. La docstring peut contenir les descriptions à la place :

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 lit une section Args:, comme ci-dessus, et une section Parameters soulignée de tirets ; une description peut s'étendre sur plusieurs lignes. Une chaîne dans Annotated l'emporte sur Option(description=), qui l'emporte sur la docstring. Une option décrite nulle part reçoit ….

Plages et réglages d'option

Range[int, 1, 10] est une option int de 1 à 10 inclus. Range accepte aussi float, et pour str les bornes portent sur la longueur : Range[str, 1, 2000]. Option(...) dans Annotated règle tout le reste, et l'emporte sur ce que l'annotation implique :

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}")
Champ de Option S'applique à Signification
name tous le nom de l'option sur Discord ; le paramètre garde son nom Python
description tous la description, quand Annotated ne contient pas de chaîne
name_localizations, description_localizations tous le texte par locale, par exemple {"fr": "salon"}
min, max entier, nombre bornes incluses, dans ±2⁵³
min_length, max_length chaîne bornes de longueur, de 0 à 6000 et de 1 à 6000
channel_types salon les types de salons que l'utilisateur peut choisir
file_types pièce jointe jusqu'à 10 valeurs parmi image, video, audio ou des extensions comme .pdf
choices chaîne, entier, nombre des choix fixes ; voir plus bas
autocomplete chaîne, entier, nombre une fonction qui suggère des valeurs pendant que l'utilisateur tape
extra tous des champs bruts fusionnés dans le JSON de l'option, pour un champ que bostie ne modélise pas encore

Un champ placé sur un type d'option auquel il ne s'applique pas lève CommandDefinitionError.

Choix

Un Literal ou un Enum propose une liste fixe. Un choix de Literal porte le nom de sa valeur ; un choix d'Enum porte le nom du membre et envoie la valeur du membre. Option(choices=[...]) les renomme ou les localise, à condition de lister les mêmes valeurs dans le même ordre :

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}.")

Le gestionnaire de /order reçoit "tea", "coffee" ou "water" pour drink, un membre de Size pour size, et la valeur du Choice choisi pour milk. Avec une simple annotation str, int ou float, Option(choices=[...]) définit à lui seul les choix ; chacun est un Choice(name, value) ou une valeur nue, qui porte son propre nom. Les valeurs des choix doivent avoir le type de l'option, et les valeurs d'un Literal ou d'un Enum doivent être toutes des str, toutes des int ou toutes des float. Une option a au plus 25 choix, un nom de choix fait de 1 à 100 caractères et une valeur de type chaîne au plus 100.

Autocomplétion

Option(autocomplete=function) demande des suggestions à function pendant que l'utilisateur tape. Elle reçoit un AutocompleteContext et le texte tapé jusque-là, et renvoie des valeurs ou des objets Choice :

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 contient les options déjà remplies, par nom d'option, converties comme le gestionnaire les recevrait quand elles se convertissent ; l'option en cours de saisie reste du texte. bostie envoie les 25 premières suggestions, tronque les noms de plus de 100 caractères, et écarte les suggestions au nom vide, à la valeur du mauvais type ou à la valeur de type chaîne de plus de 100 caractères, avec un avertissement pour chaque cas. Une option ne peut pas avoir à la fois choices et autocomplete. Si la fonction lève une exception, l'erreur part vers les hooks d'erreur et Discord reçoit une liste vide.

Convertisseurs

Annotated[T, Convert(function)] transforme la valeur de l'option en T avant l'exécution du gestionnaire. function(ctx, value) peut être async, et l'annotation de type de son paramètre value décide du type d'option Discord, descriptions Range et Annotated comprises :

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}.")

Le convertisseur ne s'exécute que pour une option que l'utilisateur a fournie ; une option facultative omise reçoit sa valeur par défaut sans conversion. Une exception levée par le convertisseur part vers les hooks d'erreur comme une exception du gestionnaire.

Sous-commandes et groupes

bot.group(name, description) enregistre une commande faite de sous-commandes, et group.group(...) y ajoute un groupe de sous-commandes. Les groupes s'imbriquent sur un seul niveau, et un groupe contient au plus 25 entrées :

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}.")

Ce code définit /roles give et /roles color set. @group.command(name=...) donne à une sous-commande un autre nom que celui de sa fonction. ctx.command_name est le nom complet qui s'est exécuté, par exemple "roles color set", et command.qualified_name donne la même chose pour l'objet commande.

Un groupe de premier niveau accepte les réglages d'une commande sauf name, ephemeral et auto_defer, et ils s'appliquent à toutes ses sous-commandes : default_permissions, nsfw, guilds, contexts et integration_types n'existent que sur les commandes de premier niveau chez Discord. Un groupe imbriqué accepte les deux localisations, checks et extra. Une sous-commande accepte name, description, les deux localisations, ephemeral, auto_defer, checks et extra. Les vérifications d'un groupe s'exécutent avant celles de ses sous-commandes.

Réglages de commande

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.")
Réglage Signification
name, description le nom et la description, à la place de ceux de la fonction
name_localizations, description_localizations le texte par locale ; voir Enregistrer les commandes pour une fonction de traduction
ephemeral les réponses ne sont visibles que par l'utilisateur, sauf si un appel en décide autrement
auto_defer le nombre de secondes après lequel une utilisation sans réponse est différée ; None le désactive (Répondre aux interactions)
default_permissions les permissions dont un membre a besoin pour voir et utiliser la commande ; les administrateurs du serveur peuvent changer qui peut l'utiliser
nsfw affichée uniquement dans les salons avec restriction d'âge
guilds les ids des serveurs où enregistrer la commande au lieu de l'enregistrer globalement
contexts où une commande globale peut être utilisée : serveurs, messages privés du bot, salons privés
integration_types quelles installations portent la commande : installation sur un serveur, installation utilisateur
checks les vérifications qui s'exécutent avant le gestionnaire (Vérifications)
extra des champs bruts fusionnés dans le JSON de la commande

ephemeral, auto_defer, default_permissions, nsfw, contexts et integration_types peuvent aussi être réglés pour toutes les commandes d'un routeur, et contexts, integration_types et auto_defer pour toutes les commandes du bot.

Limites

Le décorateur vérifie ce que Discord refuserait et lève CommandDefinitionError en nommant la fonction et le paramètre :

  • Les noms font de 1 à 32 caractères parmi les lettres, les chiffres, - et _, sans majuscule. Les noms de commandes utilisateur et de commandes de message peuvent aussi contenir des majuscules et des espaces.
  • Les descriptions font de 1 à 100 caractères.
  • Une commande a au plus 25 options et une option au plus 25 choix.
  • *args, **kwargs, les options positionnelles uniquement et les options sans annotation de type sont refusés.

Un avertissement, et non une erreur, signale quand les noms, descriptions et valeurs de choix d'une commande dépassent au total les 8000 caractères que Discord autorise. La synchronisation avertit quand une même portée contient plus de 100 commandes slash, 15 commandes utilisateur ou 15 commandes de message.

Ensuite