Перейти к содержанию

Слэш-команды

Бости пишет слэш-команду в облачке с репликой

Слэш-команда — это асинхронная функция с декоратором @bot.command. Её первый параметр принимает Context; каждый следующий параметр — это параметр команды.

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)

Команда называется /report. Её имя — это имя функции, а описание — первая строка докстринга; @bot.command(name=..., description=...) задаёт их явно, а команда без того и другого получает описание …. Работают и @bot.command, и @bot.command(...) — как на боте, так и на роутере.

Типы параметров

Аннотация типа параметра выбирает тип параметра команды, а обработчик получает уже преобразованное значение:

Аннотация Параметр Discord Обработчик получает
str строка текст
int целое число int
float число float, даже если пользователь ввёл целое число
bool логическое значение bool
User пользователь User
ResolvedMember пользователь участника вместе с его user; только на серверах
Role роль Role
PartialChannel канал канал в том виде, в каком его разрешило взаимодействие
Attachment вложение Attachment загруженного файла
User \| Role или ResolvedMember \| Role упоминаемый объект то, что выбрал пользователь
Literal[...] или Enum строка, целое число или число с вариантами литерал или член перечисления

Аннотация, которой нет в таблице, вызывает CommandDefinitionError при выполнении декоратора. Для параметра-канала аннотация — PartialChannel, а не Channel, а для участника — ResolvedMember, а не Member: взаимодействие несёт именно эти частичные объекты, о чём и говорит сообщение об ошибке.

Обязательные и необязательные

Параметр без значения по умолчанию обязателен. Значение по умолчанию или | None в аннотации делают его необязательным; необязательный параметр, который пользователь не указал, получает значение по умолчанию или None. Discord требует, чтобы обязательные параметры шли первыми, и bostie сам сортирует их так при сборке команды, в каком бы порядке ни шли параметры функции.

Описания

Строка в Annotated — это описание параметра: Annotated[str, "What happened"]. Вместо этого описания можно держать в докстринге:

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 читает раздел Args:, как выше, и раздел Parameters, подчёркнутый дефисами; описание может занимать несколько строк. Строка в Annotated важнее Option(description=), а тот важнее докстринга. Параметр, который нигде не описан, получает ….

Диапазоны и настройки параметров

Range[int, 1, 10] — параметр int от 1 до 10 включительно. Range принимает и float, а для str границы задают длину: Range[str, 1, 2000]. Всё остальное задаёт Option(...) в Annotated, и он важнее того, что следует из аннотации:

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 Применяется к Значение
name всем имя параметра в Discord; параметр функции сохраняет своё имя в Python
description всем описание, если в Annotated нет строки
name_localizations, description_localizations всем текст для каждой локали, например {"fr": "salon"}
min, max целому числу, числу границы включительно, в пределах ±2⁵³
min_length, max_length строке границы длины: от 0 до 6000 и от 1 до 6000
channel_types каналу типы каналов, которые может выбрать пользователь
file_types вложению до 10 значений из image, video, audio или расширений вроде .pdf
choices строке, целому числу, числу фиксированные варианты; см. ниже
autocomplete строке, целому числу, числу функция, которая предлагает значения, пока пользователь печатает
extra всем сырые поля, которые добавляются в JSON параметра, — для поля, которое bostie ещё не моделирует

Поле, указанное для типа параметра, к которому оно не применяется, вызывает CommandDefinitionError.

Варианты

Literal или Enum предлагают фиксированный список. Вариант из Literal называется по своему значению; вариант из Enum называется по имени члена и отправляет значение члена. Option(choices=[...]) переименовывает или локализует их, если перечисляет те же значения в том же порядке:

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

Обработчик /order получает "tea", "coffee" или "water" для drink, член Size для size и значение выбранного Choice для milk. При простой аннотации str, int или float варианты задаёт один только Option(choices=[...]); каждый из них — это Choice(name, value) или просто значение, которое называется само по себе. Значения вариантов должны иметь тип параметра, а значения Literal или Enum должны быть все str, все int или все float. У параметра не больше 25 вариантов, имя варианта — от 1 до 100 символов, а строковое значение — не длиннее 100.

Автодополнение

Option(autocomplete=function) запрашивает у function подсказки, пока пользователь печатает. Функция получает AutocompleteContext и уже введённый текст и возвращает значения или объекты 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 хранит уже заполненные параметры по их именам, преобразованные так, как их получил бы обработчик, если преобразование удаётся; параметр, который сейчас вводится, остаётся текстом. bostie отправляет первые 25 подсказок, обрезает имена длиннее 100 символов и отбрасывает подсказки с пустым именем, значением неверного типа или строковым значением длиннее 100 символов, с предупреждением в каждом случае. У параметра не может быть одновременно choices и autocomplete. Если функция выбрасывает исключение, ошибка уходит в хуки ошибок, а Discord получает пустой список.

Конвертеры

Annotated[T, Convert(function)] превращает значение параметра в T до запуска обработчика. function(ctx, value) может быть асинхронной, а аннотация типа её параметра value определяет тип параметра в Discord, включая Range и описания из 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}.")

Конвертер выполняется только для параметра, который указал пользователь; необязательный параметр, который не указали, получает значение по умолчанию без преобразования. Исключение из конвертера уходит в хуки ошибок так же, как исключение из обработчика.

Подкоманды и группы

bot.group(name, description) регистрирует команду, состоящую из подкоманд, а group.group(...) добавляет внутрь неё группу подкоманд. Группы вкладываются только на один уровень, и в группе не больше 25 элементов:

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

Так определяются /roles give и /roles color set. @group.command(name=...) даёт подкоманде имя, отличное от имени функции. ctx.command_name — полное имя выполненной команды, например "roles color set", а command.qualified_name — то же самое для объекта команды.

Группа верхнего уровня принимает настройки команды, кроме name, ephemeral и auto_defer, и они действуют на все её подкоманды: default_permissions, nsfw, guilds, contexts и integration_types в Discord существуют только у команд верхнего уровня. Вложенная группа принимает обе локализации, checks и extra. Подкоманда принимает name, description, обе локализации, ephemeral, auto_defer, checks и extra. Проверки группы выполняются до проверок её подкоманд.

Настройки команды

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.")
Настройка Значение
name, description имя и описание вместо взятых из функции
name_localizations, description_localizations текст для каждой локали; функция перевода описана в «Регистрации команд»
ephemeral ответы видит только пользователь, если вызов не указывает иное
auto_defer через сколько секунд ответ на неотвеченное использование откладывается; None отключает это («Ответы на взаимодействия»)
default_permissions права, которые нужны участнику, чтобы видеть и использовать команду; администраторы сервера могут изменить, кому она доступна
nsfw команда видна только в каналах с возрастным ограничением
guilds ID серверов, на которых команда регистрируется вместо глобальной регистрации
contexts где можно использовать глобальную команду: на серверах, в личных сообщениях с ботом, в приватных каналах
integration_types в каких установках есть команда: установка на сервер, установка для пользователя
checks проверки, которые выполняются до обработчика («Проверки»)
extra сырые поля, которые добавляются в JSON команды

ephemeral, auto_defer, default_permissions, nsfw, contexts и integration_types можно также задать для всех команд роутера, а contexts, integration_types и auto_defer — для всех команд бота.

Лимиты

Декоратор проверяет то, что отклонил бы Discord, и выбрасывает CommandDefinitionError с именем функции и параметра:

  • Имена — от 1 до 32 символов из букв, цифр, - и _, без заглавных букв. Имена команд пользователя и сообщения могут также содержать заглавные буквы и пробелы.
  • Описания — от 1 до 100 символов.
  • У команды не больше 25 параметров, у параметра — не больше 25 вариантов.
  • *args, **kwargs, параметры только для позиционной передачи и параметры без аннотации типа не принимаются.

Если имена, описания и значения вариантов команды в сумме превышают разрешённые Discord 8000 символов, об этом сообщает предупреждение, а не ошибка. Синхронизация предупреждает, когда в одной области больше 100 слэш-команд, 15 команд пользователя или 15 команд сообщения.

Дальше