Pular para conteúdo

Comandos de barra

Bostie escrevendo um comando de barra em um balão de fala

Um comando de barra é uma função assíncrona decorada com @bot.command. O primeiro parâmetro recebe o Context; cada parâmetro depois dele é uma opção do comando.

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)

O comando é /report. O nome dele é o nome da função e a descrição é a primeira linha da docstring; @bot.command(name=..., description=...) os define no lugar disso, e um comando sem nenhum dos dois recebe … como descrição. @bot.command e @bot.command(...) funcionam os dois, no bot ou em um router.

Tipos de opção

A anotação de tipo de um parâmetro escolhe o tipo da opção, e o handler recebe o valor já convertido:

Anotação Opção no Discord O handler recebe
str string o texto
int integer um int
float number um float, mesmo quando o usuário digitou um número inteiro
bool boolean um bool
User user o User
ResolvedMember user o membro, com o seu user; só em servidores
Role role o Role
PartialChannel channel o canal como a interação o resolveu
Attachment attachment o Attachment do arquivo enviado
User \| Role ou ResolvedMember \| Role mentionable o que o usuário tiver escolhido
Literal[...] ou um Enum string, integer ou number com escolhas o literal ou o membro do enum

Uma anotação que a tabela não lista levanta CommandDefinitionError quando o decorador roda. Para uma opção de canal a anotação é PartialChannel, não Channel, e para um membro é ResolvedMember, não Member: uma interação traz esses objetos parciais, e a mensagem de erro diz isso.

Obrigatórias e opcionais

Uma opção sem valor padrão é obrigatória. Um valor padrão, ou | None na anotação, a torna opcional; uma opção opcional que o usuário deixou de fora recebe o seu padrão, ou None. O Discord quer as opções obrigatórias primeiro, e o bostie as ordena assim quando monta o comando, seja qual for a ordem dos parâmetros.

Descrições

Uma string em Annotated é a descrição da opção: Annotated[str, "What happened"]. A docstring pode guardar as descrições no lugar disso:

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)

O bostie lê uma seção Args:, como acima, e uma seção Parameters sublinhada com hifens; uma descrição pode ocupar várias linhas. Uma string em Annotated tem prioridade sobre Option(description=), que tem prioridade sobre a docstring. Uma opção sem descrição em lugar nenhum recebe ….

Intervalos e configurações de opção

Range[int, 1, 10] é uma opção int de 1 a 10, inclusive. Range também aceita float, e para str os limites são o comprimento: Range[str, 1, 2000]. Option(...) em Annotated define todo o resto, e tem prioridade sobre o que a anotação implica:

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}")
Campo de Option Vale para Significado
name todas o nome da opção no Discord; o parâmetro mantém o seu nome em Python
description todas a descrição, quando Annotated não tem nenhuma string
name_localizations, description_localizations todas texto por idioma, como {"fr": "salon"}
min, max integer, number limites inclusivos, dentro de ±2⁵³
min_length, max_length string limites de comprimento, de 0 a 6000 e de 1 a 6000
channel_types channel os tipos de canal que o usuário pode escolher
file_types attachment até 10 entre image, video, audio ou extensões como .pdf
choices string, integer, number escolhas fixas; veja abaixo
autocomplete string, integer, number uma função que sugere valores enquanto o usuário digita
extra todas campos brutos mesclados no JSON da opção, para um campo que o bostie ainda não modela

Um campo em um tipo de opção ao qual ele não se aplica levanta CommandDefinitionError.

Escolhas

Um Literal ou um Enum oferece uma lista fixa. Uma escolha de Literal recebe o nome do seu valor; uma escolha de Enum recebe o nome do membro e envia o valor do membro. Option(choices=[...]) as renomeia ou traduz, desde que liste os mesmos valores na mesma ordem:

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

O handler de /order recebe "tea", "coffee" ou "water" para drink, um membro de Size para size e o valor do Choice escolhido para milk. Com uma anotação simples str, int ou float, só Option(choices=[...]) define as escolhas; cada uma é um Choice(name, value) ou um valor solto, que recebe o próprio valor como nome. Os valores das escolhas precisam ter o tipo da opção, e os valores de um Literal ou de um Enum precisam ser todos str, todos int ou todos float. Uma opção tem no máximo 25 escolhas, o nome de uma escolha tem de 1 a 100 caracteres e um valor string no máximo 100.

Preenchimento automático

Option(autocomplete=function) pede sugestões a function enquanto o usuário digita. Ela recebe um AutocompleteContext e o texto digitado até o momento, e retorna valores ou objetos 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 guarda as opções preenchidas até agora, pelo nome da opção, convertidas como o handler as receberia quando a conversão dá certo; a opção sendo digitada fica como texto. O bostie envia as 25 primeiras sugestões, corta nomes com mais de 100 caracteres e descarta sugestões com nome vazio, valor do tipo errado ou valor string com mais de 100 caracteres, com um aviso para cada caso. Uma opção não pode ter choices e autocomplete ao mesmo tempo. Se a função levantar uma exceção, o erro vai para os hooks de erro e o Discord recebe uma lista vazia.

Conversores

Annotated[T, Convert(function)] transforma o valor da opção em um T antes que o handler rode. function(ctx, value) pode ser assíncrona, e a anotação de tipo do seu parâmetro value decide o tipo da opção no Discord, incluindo Range e as descrições em Annotated:

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

O conversor só roda para uma opção que o usuário informou; uma opção opcional deixada de fora recebe o seu padrão sem conversão. Uma exceção vinda do conversor vai para os hooks de erro como uma exceção vinda do handler.

Subcomandos e grupos

bot.group(name, description) registra um comando feito de subcomandos, e group.group(...) adiciona dentro dele um grupo de subcomandos. Os grupos se aninham em um só nível, e um grupo tem no máximo 25 entradas:

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

Isso define /roles give e /roles color set. @group.command(name=...) dá a um subcomando um nome diferente do da sua função. ctx.command_name é o nome completo que foi executado, como "roles color set", e command.qualified_name é o mesmo para o objeto do comando.

Um grupo de nível superior aceita as configurações de um comando, exceto name, ephemeral e auto_defer, e elas valem para todos os seus subcomandos: default_permissions, nsfw, guilds, contexts e integration_types só existem em comandos de nível superior no Discord. Um grupo aninhado aceita as duas traduções, checks e extra. Um subcomando aceita name, description, as duas traduções, ephemeral, auto_defer, checks e extra. As verificações de um grupo rodam antes das dos seus subcomandos.

Configurações do comando

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.")
Configuração Significado
name, description o nome e a descrição, no lugar dos da função
name_localizations, description_localizations texto por idioma; veja Registrando comandos para uma função de tradução
ephemeral as respostas ficam visíveis só para o usuário, a menos que uma chamada diga o contrário
auto_defer segundos após os quais um uso sem resposta tem a resposta adiada; None desativa (Respondendo a interações)
default_permissions as permissões de que um membro precisa para ver e usar o comando; os administradores do servidor podem mudar quem pode usá-lo
nsfw mostrado só em canais com restrição de idade
guilds IDs dos servidores onde registrar o comando, em vez de globalmente
contexts onde um comando global pode ser usado: servidores, as mensagens diretas do bot, canais privados
integration_types quais instalações trazem o comando: instalação no servidor, instalação no usuário
checks verificações que rodam antes do handler (Verificações)
extra campos brutos mesclados no JSON do comando

ephemeral, auto_defer, default_permissions, nsfw, contexts e integration_types também podem ser definidos para todos os comandos de um router, e contexts, integration_types e auto_defer para todos os comandos do bot.

Limites

O decorador verifica o que o Discord rejeitaria e levanta CommandDefinitionError nomeando a função e o parâmetro:

  • Os nomes têm de 1 a 32 caracteres entre letras, dígitos, - e _, sem nenhuma letra maiúscula. Os nomes de comandos de usuário e de mensagem também podem ter letras maiúsculas e espaços.
  • As descrições têm de 1 a 100 caracteres.
  • Um comando tem no máximo 25 opções e uma opção no máximo 25 escolhas.
  • *args, **kwargs, opções somente posicionais e opções sem anotação de tipo são recusadas.

Um aviso, e não um erro, informa quando os nomes, as descrições e os valores das escolhas de um comando somam mais que os 8000 caracteres que o Discord permite. A sincronização avisa quando um escopo tem mais de 100 comandos de barra, 15 comandos de usuário ou 15 comandos de mensagem.

Próximos passos