Saltar a contenido

Checks, hooks y errores

Bostie de brazos cruzados, echando humo

Un check decide si un handler puede ejecutarse. Los hooks ejecutan código antes y después de los handlers. Los hooks de errores responden cuando algo lanza una excepción.

Checks

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)

Un check es una función que recibe el contexto y devuelve un bool, de forma síncrona o asíncrona. False rechaza la ejecución con CheckFailure, y un check puede lanzar su propio CheckFailure: su mensaje es lo que se le dice al usuario. Los checks van en el ajuste checks=[...] de un comando, grupo, subcomando, comando de usuario o de mensaje, punto de entrada, o ruta de componente o de modal, y @router.check registra uno para todos los handlers de un router; el bot es un router.

Primero se ejecutan los checks del bot, luego los de cada router desde el más externo hacia adentro, luego los de los grupos de arriba hacia abajo y, por último, los del propio comando. El primer rechazo detiene la ejecución. El autocompletado no ejecuta checks.

bostie.checks tiene los más comunes:

Check Rechaza con Cuándo
has_permissions(permissions) MissingPermissions al usuario le falta alguno de los permissions en el canal, o está en un MD
bot_has_permissions(permissions) BotMissingPermissions al bot le falta alguno de ellos en el canal
Cooldown(rate, seconds, per="user") CooldownActive la clave ya lo usó rate veces en los últimos seconds
MaxConcurrency(number, per="global") MaxConcurrencyReached todavía hay number ejecuciones en curso para la clave
has_role(role) MissingRole el usuario no tiene el rol, indicado por id o por nombre, o está en un MD
has_any_role(*roles) MissingRole el usuario no tiene ninguno de los roles
is_owner NotOwner el usuario no está entre los bot.owner_ids

Un objeto check que además tiene un método release(ctx), un checks.ReleasingCheck como MaxConcurrency, se libera cuando termina el handler, sin importar cómo termine.

per es "user", "guild" (el usuario, en los MD), "channel", "global" o una función del contexto que devuelve cualquier clave hasheable. Los checks de permisos leen los permisos que Discord envía con la interacción, así que no necesitan caché ni solicitudes. Sus errores traen missing, los Permissions que faltan, y CooldownActive trae retry_after en segundos.

Roles y propietarios

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 y has_any_role aceptan ids o nombres de roles. Un id se compara con los roles que Discord envía con la interacción, así que no necesita caché. Un nombre se busca entre los roles del servidor en caché, así que un bot sin caché, o una aplicación solo por HTTP, indica los roles por id. MissingRole trae los roles que se pidieron, y su mensaje menciona un rol indicado por id, que Discord muestra con el nombre del rol.

is_owner es un check tal cual, sin paréntesis. bot.owner_ids contiene los usuarios que deja pasar: los owner_ids pasados a Bot(...) o, si no hay, el propietario de la aplicación, que se lee de la aplicación cuando arranca el bot. En una aplicación que pertenece a un equipo, son el propietario del equipo y los administradores y desarrolladores que aceptaron la invitación; los miembros de solo lectura quedan afuera.

Lo que se le dice al usuario

Cuando ningún hook de errores maneja un CheckFailure, bostie responde con su mensaje, de forma efímera: "You need the Manage Messages permission.", "The bot needs the Manage Messages permission.", "Try again" con una marca de tiempo relativa como "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.", o "You cannot use this." para un False simple.

Hooks antes y después

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 registra un async def hook(ctx) que se ejecuta después de que pasaron los checks y antes del handler; @router.after_invoke, uno que se ejecuta después del handler, también cuando lanzó una excepción. Se ejecutan para cada comando, ruta de componente y ruta de modal del router, así que un hook que puede ver un componente no debería dar por hecho un Context. Los hooks previos del bot se ejecutan primero y sus hooks posteriores, al final. Una excepción de un hook posterior se registra en el log y no cambia la respuesta.

Hooks de errores

Una excepción de un check, un conversor, un hook previo o el handler va a un hook de errores. @x.on_error registra un async def hook(ctx, error) para cualquier Exception, y @x.on_error(NotFound, ValueError) para esos tipos y sus subclases. x es un comando, un grupo, una ruta de componente o de modal, un router o el bot, y cada uno puede tener un hook por tipo de excepción.

bostie busca un hook en este orden, y el primero que encuentra maneja el error:

  1. el comando, luego su grupo y el grupo de nivel superior;
  2. el router al que pertenece el comando, luego cada router en el que está incluido, hasta llegar al bot.

Dentro de un mismo lugar, gana el hook de la clase más cercana a la del error: un hook para NotFound le gana a uno para HTTPException. Un hook que vuelve a lanzar el mismo error se lo pasa al siguiente hook en ese orden, como hace slow_down arriba. Un hook que lanza otro error hace que se registre en el log.

El tipo del contexto es ErrorContext: un Context, ComponentContext, ModalContext o AutocompleteContext. El contexto de autocompletado no puede llamar a respond, así que compruébalo como arriba.

Cuando un hook termina sin responder, bostie deja una advertencia en el log y responde "Something went wrong.". Cuando ningún hook maneja el error, bostie responde a un CheckFailure con su mensaje, y registra cualquier otra cosa en el log con su traceback antes de responder "Something went wrong.". No se responde nada cuando el handler ya había respondido, y un autocompletado recibe una lista vacía de sugerencias.

Errores fuera de los handlers

Los listeners y las tareas no tienen un usuario al que responder. Una excepción en un listener se envía como un evento ListenerError, y una en una tarea como un TaskError; si no hay un listener para ellos, bostie los registra en el log. Ver Eventos y Tareas en segundo plano.

Tipos de excepción

Todo lo que bostie lanza sobre Discord, la red o el estado del bot es un BostieError. Un argumento incorrecto lanza ValueError o TypeError. Algunos errores también son una excepción estándar: StateError es un RuntimeError, MissingIntent un ValueError, StartupError un OSError y CommandDefinitionError un TypeError. Los errores de comandos e interacciones comparten la base CommandError, y los errores de voz, VoiceError.

Excepción Se lanza cuando
HTTPException una solicitud REST terminó con un código de error; status, code (un JSONErrorCode), message, errors y el body sin procesar
BadRequest, Forbidden, NotFound, ServerError 400, 403, 404 y 5xx
RateLimited 429, o bostie retuvo una solicitud que esperaría más que max_rate_limit_wait; retry_after, is_global
InvalidToken Discord rechazó el token, en REST o en el gateway
TransportError una solicitud falló antes de que Discord respondiera
UnexpectedResponse Discord aceptó una solicitud pero su respuesta no se puede decodificar; raw la contiene
RequestBudgetExhausted demasiadas solicitudes recientes fueron inválidas, y bostie rechazó una para proteger la IP
GatewayClosed, PrivilegedIntentsRequired, ShardingRequired Discord cerró el gateway de forma definitiva; los dos últimos para los códigos 4014 y 4011
ShardNotRunning, GatewayQueueFull un comando del gateway para un shard que este proceso no ejecuta, o con demasiados comandos en espera
StateError una llamada que el estado del objeto no permite, como bot.rest antes de start()
MissingIntent una función necesita un intent que el bot no pidió
MemberRequestRateLimited Discord rechazó una solicitud de miembros; retry_after
StartupError bostie no pudo preparar un recurso del sistema que necesita
CommandDefinitionError un comando, grupo u opción rompe las reglas de Discord; lo lanza el decorador
CommandSyncError Discord rechazó los comandos durante una sincronización
OptionConversionError una opción u objetivo no se pudo convertir a su anotación
InteractionResponded, InteractionNotAnswered, InteractionExpired una llamada para responder que el estado de la interacción no permite
CheckFailure y sus subclases un check rechazó una ejecución

Los errores de voz están en la página de voz. La referencia de errores enumera cada clase con sus atributos.