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

Проверки, хуки и ошибки

Бости со скрещёнными лапами кипит от злости

Проверка решает, может ли обработчик выполниться. Хуки выполняют код до и после обработчиков. Хуки ошибок отвечают, когда что-то выбрасывает исключение.

Проверки

import bostie
from bostie import CheckFailure, Context, Permissions
from bostie.checks import Cooldown, MaxConcurrency, bot_has_permissions, has_permissions

bot = bostie.Bot()
BANNED_USERS = {80351110224678912}


@bot.check
def not_banned(ctx: Context) -> bool:
    if ctx.user.id in BANNED_USERS:
        raise CheckFailure("You are banned from using this bot.")
    return True


async def in_a_guild(ctx: Context) -> bool:
    return ctx.guild_id is not None


@bot.command(
    checks=[
        in_a_guild,
        has_permissions(Permissions.MANAGE_MESSAGES),
        bot_has_permissions(Permissions.MANAGE_MESSAGES),
        Cooldown(2, 60, per="channel"),
        MaxConcurrency(1, per="guild"),
    ]
)
async def clean(ctx: Context, count: int = 20) -> None:
    """Delete recent messages."""
    await ctx.respond(f"Deleting {count} messages.", ephemeral=True)

Проверка — это функция, которая принимает контекст и возвращает bool, сразу или асинхронно. False отклоняет запуск с CheckFailure, а проверка может и сама выбросить свой CheckFailure: его сообщение и увидит пользователь. Проверки задаются в настройке checks=[...] команды, группы, подкоманды, команды пользователя или сообщения, точки входа, а также маршрута компонента или модального окна, а @router.check регистрирует проверку для всех обработчиков роутера; бот — тоже роутер.

Сначала выполняются проверки бота, затем проверки каждого роутера снаружи внутрь, затем проверки групп сверху вниз и наконец собственные проверки команды. Первый отказ останавливает запуск. Автодополнение проверки не выполняет.

В bostie.checks есть распространённые проверки:

Проверка Отказывает с Когда
has_permissions(permissions) MissingPermissions у пользователя в канале нет хотя бы одного из прав permissions или он в личных сообщениях
bot_has_permissions(permissions) BotMissingPermissions у бота в канале нет хотя бы одного из них
Cooldown(rate, seconds, per="user") CooldownActive ключ уже использовал её rate раз за последние seconds секунд
MaxConcurrency(number, per="global") MaxConcurrencyReached для ключа всё ещё идут number запусков
has_role(role) MissingRole у пользователя нет роли, заданной ID или именем, или он в личных сообщениях
has_any_role(*roles) MissingRole у пользователя нет ни одной из ролей
is_owner NotOwner пользователь не входит в bot.owner_ids

Объект проверки, у которого есть ещё и метод release(ctx), — checks.ReleasingCheck, такой как MaxConcurrency, — освобождается, когда обработчик завершается, как бы он ни завершился.

per — это "user", "guild" (в личных сообщениях — пользователь), "channel", "global" или функция от контекста, возвращающая любой хешируемый ключ. Проверки прав читают права, которые Discord присылает вместе со взаимодействием, поэтому им не нужен ни кэш, ни запрос. Их ошибки несут missing — недостающие Permissions, а CooldownActive несёт retry_after в секундах.

Роли и владельцы

import bostie
from bostie import Context, ResolvedMember, Router
from bostie.checks import has_any_role, has_role, is_owner

bot = bostie.Bot()
owner = Router()
owner.check(is_owner)
MODERATOR_ROLE = 1335174302269571073


@owner.command
async def reload(ctx: Context, module: str) -> None:
    """Load a module of the bot again."""
    bot.reload(module)
    await ctx.respond(f"Reloaded {module}.", ephemeral=True)


@bot.command(checks=[has_role(MODERATOR_ROLE)])
async def warn(ctx: Context, member: ResolvedMember) -> None:
    """Warn a member."""
    await ctx.respond(f"{member.mention}, please follow the rules.")


@bot.command(checks=[has_any_role("Admin", "Event Host")])
async def announce(ctx: Context, text: str) -> None:
    """Post an announcement."""
    await ctx.respond(text)


bot.include(owner)

has_role и has_any_role принимают ID или имена ролей. ID сверяется с ролями, которые Discord присылает вместе со взаимодействием, поэтому кэш не нужен. Имя ищется среди ролей сервера в кэше, поэтому бот без кэша или приложение только на HTTP указывает роли по ID. MissingRole несёт запрошенные roles, а его сообщение упоминает роль, заданную по ID, и Discord показывает её как имя роли.

is_owner — это проверка сама по себе, без скобок. bot.owner_ids хранит пользователей, которых она пропускает: owner_ids, переданные в Bot(...), а иначе владельца приложения, которого бот читает из приложения при запуске. Для приложения, принадлежащего команде, это владелец команды, а также администраторы и разработчики, принявшие приглашение; участники только с правом чтения не учитываются.

Что увидит пользователь

Если ни один хук ошибок не обработал CheckFailure, bostie отвечает его сообщением, эфемерно: «You need the Manage Messages permission.», «The bot needs the Manage Messages permission.», «Try again» с относительной меткой времени вроде «in 42 seconds», «This is already running; try again once it is done.», «You need the Moderator role.», «Only the bot's owners can use this.» или «You cannot use this.» для простого False.

Хуки до и после

import logging
import time

import bostie
from bostie import (
    AutocompleteContext,
    Context,
    CooldownActive,
    ErrorContext,
    Forbidden,
    NotFound,
)

log = logging.getLogger("my-bot")
bot = bostie.Bot()
started: dict[int, float] = {}


@bot.before_invoke
async def start_timer(ctx: Context) -> None:
    started[ctx.interaction.id] = time.monotonic()


@bot.after_invoke
async def log_duration(ctx: Context) -> None:
    took = time.monotonic() - started.pop(ctx.interaction.id)
    log.info("/%s took %.2f s", ctx.command_name, took)


@bot.command
async def fetch(ctx: Context, message_id: str) -> None:
    """Show a message of this channel."""
    if ctx.channel_id is not None:
        message = await bot.rest.messages.fetch(ctx.channel_id, int(message_id))
        await ctx.respond(message.content or "(no text)")


@fetch.on_error(NotFound, ValueError)
async def no_such_message(ctx: ErrorContext, error: NotFound | ValueError) -> None:
    if not isinstance(ctx, AutocompleteContext):
        await ctx.respond("No message has that id here.", ephemeral=True)


@bot.on_error(Forbidden)
async def missing_access(ctx: ErrorContext, error: Forbidden) -> None:
    if not isinstance(ctx, AutocompleteContext):
        await ctx.respond("I cannot see that channel.", ephemeral=True)


@bot.on_error(CooldownActive)
async def slow_down(ctx: ErrorContext, error: CooldownActive) -> None:
    log.info("%s hit a cooldown, %.0f s left", ctx.user, error.retry_after)
    raise error

@router.before_invoke регистрирует async def hook(ctx), который выполняется после прохождения проверок и до обработчика; @router.after_invoke — хук, который выполняется после обработчика, даже если тот выбросил исключение. Они выполняются для каждой команды, маршрута компонента и маршрута модального окна роутера, поэтому хук, который может увидеть компонент, не должен рассчитывать на Context. Хуки «до» у бота выполняются первыми, а хуки «после» — последними. Исключение из хука «после» пишется в лог и не меняет ответ.

Хуки ошибок

Исключение из проверки, конвертера, хука «до» или обработчика уходит в хук ошибок. @x.on_error регистрирует async def hook(ctx, error) для любого Exception, а @x.on_error(NotFound, ValueError) — для этих типов и их подклассов. x — это команда, группа, маршрут компонента или модального окна, роутер или бот, и у каждого может быть по одному хуку на тип исключения.

bostie ищет хук в таком порядке, и первый найденный обрабатывает ошибку:

  1. команда, затем её группа и группа верхнего уровня;
  2. роутер, которому принадлежит команда, затем каждый роутер, в который он включён, вплоть до бота.

В пределах одного места побеждает хук для ближайшего класса ошибки: хук для NotFound важнее хука для HTTPException. Хук, который выбрасывает ту же ошибку, передаёт её следующему хуку в этом порядке, как делает slow_down выше. Если хук выбрасывает другую ошибку, она пишется в лог.

Тип контекста — ErrorContext: Context, ComponentContext, ModalContext или AutocompleteContext. Контекст автодополнения не может вызвать respond, поэтому проверяйте его тип, как выше.

Если хук завершился без ответа, bostie пишет в лог предупреждение и отвечает «Something went wrong.». Если ни один хук не обработал ошибку, bostie отвечает на CheckFailure его сообщением, а всё остальное пишет в лог с трассировкой и затем отвечает «Something went wrong.». Если обработчик уже ответил, ничего не отправляется, а автодополнение получает пустой список подсказок.

Ошибки вне обработчиков

У слушателей и задач нет пользователя, которому можно ответить. Исключение в слушателе отправляется как событие ListenerError, а в задаче — как TaskError; если их никто не слушает, bostie пишет их в лог. См. «События» и «Фоновые задачи».

Типы исключений

Всё, что bostie выбрасывает в связи с Discord, сетью или состоянием бота, — это BostieError. Неверный аргумент вместо этого вызывает ValueError или TypeError. Некоторые ошибки являются ещё и стандартными исключениями: StateError — это RuntimeError, MissingIntent — ValueError, StartupError — OSError, а CommandDefinitionError — TypeError. У ошибок команд и взаимодействий общий базовый класс CommandError, а у ошибок голоса — VoiceError.

Исключение Когда выбрасывается
HTTPException REST-запрос завершился статусом ошибки; status, code (JSONErrorCode), message, errors и сырое body
BadRequest, Forbidden, NotFound, ServerError 400, 403, 404 и 5xx
RateLimited 429, или bostie придержал запрос, который ждал бы дольше max_rate_limit_wait; retry_after, is_global
InvalidToken Discord отклонил токен — в REST или в шлюзе
TransportError запрос не удался до того, как Discord ответил
UnexpectedResponse Discord принял запрос, но его ответ не декодируется; он хранится в raw
RequestBudgetExhausted слишком много недавних запросов были недействительными, и bostie отклонил очередной, чтобы защитить IP
GatewayClosed, PrivilegedIntentsRequired, ShardingRequired Discord окончательно закрыл шлюз; последние два — для кодов 4014 и 4011
ShardNotRunning, GatewayQueueFull команда шлюза для шарда, который этот процесс не запускает, или при слишком большом числе ожидающих команд
StateError вызов, который не допускает состояние объекта, например bot.rest до start()
MissingIntent возможности нужен интент, который бот не запросил
MemberRequestRateLimited Discord отклонил запрос участников; retry_after
StartupError bostie не смог настроить нужный ему системный ресурс
CommandDefinitionError команда, группа или параметр нарушают правила Discord; выбрасывается декоратором
CommandSyncError Discord отклонил команды во время синхронизации
OptionConversionError параметр или цель не преобразовались в тип из аннотации
InteractionResponded, InteractionNotAnswered, InteractionExpired вызов ответа, который не допускает состояние взаимодействия
CheckFailure и его подклассы проверка отклонила запуск

Ошибки голоса описаны на странице о голосе. Справочник по ошибкам перечисляет все классы с их атрибутами.