Aller au contenu

Vérifications, hooks et erreurs

Bostie les bras croisés, furieux

Une vérification décide si un gestionnaire peut s'exécuter. Les hooks exécutent du code avant et après les gestionnaires. Les hooks d'erreur répondent quand quelque chose lève une exception.

Vérifications

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)

Une vérification est une fonction qui prend le contexte et renvoie un bool, directement ou avec await. False refuse l'exécution avec CheckFailure, et une vérification peut lever sa propre CheckFailure : son message est ce que l'utilisateur voit. Les vérifications se placent dans le réglage checks=[...] d'une commande, d'un groupe, d'une sous-commande, d'une commande utilisateur ou de message, d'un point d'entrée, ou d'une route de composant ou de fenêtre modale, et @router.check en enregistre une pour chaque gestionnaire d'un routeur ; le bot est un routeur.

Les vérifications du bot s'exécutent en premier, puis celles de chaque routeur, du plus externe vers l'intérieur, puis celles des groupes, de haut en bas, puis celles de la commande elle-même. Le premier refus arrête l'exécution. L'autocomplétion n'exécute aucune vérification.

bostie.checks fournit les plus courantes :

Vérification Refuse avec Quand
has_permissions(permissions) MissingPermissions il manque à l'utilisateur l'une des permissions dans le salon, ou il est en MP
bot_has_permissions(permissions) BotMissingPermissions il manque au bot l'une d'elles dans le salon
Cooldown(rate, seconds, per="user") CooldownActive la clé l'a déjà utilisée rate fois au cours des seconds dernières secondes
MaxConcurrency(number, per="global") MaxConcurrencyReached number exécutions pour la clé sont encore en cours
has_role(role) MissingRole l'utilisateur n'a pas le rôle, donné par id ou par nom, ou il est en MP
has_any_role(*roles) MissingRole l'utilisateur n'a aucun des rôles
is_owner NotOwner l'utilisateur ne fait pas partie de bot.owner_ids

Un objet de vérification qui possède aussi une méthode release(ctx), un checks.ReleasingCheck comme MaxConcurrency, est libéré dès que le gestionnaire se termine, quelle que soit la façon dont il se termine.

per vaut "user", "guild" (l'utilisateur, en MP), "channel", "global", ou une fonction du contexte qui renvoie n'importe quelle clé hachable. Les vérifications de permissions lisent les permissions que Discord envoie avec l'interaction : elles ne nécessitent donc ni cache ni requête. Leurs erreurs portent missing, les Permissions manquantes, et CooldownActive porte retry_after en secondes.

Rôles et propriétaires

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 et has_any_role acceptent des ids ou des noms de rôles. Un id est comparé aux rôles que Discord envoie avec l'interaction : il ne nécessite donc pas de cache. Un nom est recherché parmi les rôles du serveur en cache, si bien qu'un bot sans cache, ou une application HTTP uniquement, désigne les rôles par id. MissingRole porte les roles demandés, et son message mentionne un rôle donné par id, que Discord affiche sous le nom du rôle.

is_owner est une vérification telle quelle, sans parenthèses. bot.owner_ids contient les utilisateurs qu'elle laisse passer : les owner_ids passés à Bot(...), sinon le propriétaire de l'application, lu depuis l'application au démarrage du bot. Pour une application qui appartient à une équipe, ce sont le propriétaire de l'équipe ainsi que les admins et les développeurs qui ont accepté l'invitation ; les membres en lecture seule sont exclus.

Ce que voit l'utilisateur

Quand aucun hook d'erreur ne traite une CheckFailure, bostie répond avec son message, en éphémère : « You need the Manage Messages permission. », « The bot needs the Manage Messages permission. », « Try again » suivi d'un horodatage relatif comme « 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. », ou « You cannot use this. » pour un simple False.

Hooks avant et aprè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 enregistre un async def hook(ctx) qui s'exécute une fois les vérifications passées et avant le gestionnaire ; @router.after_invoke en enregistre un qui s'exécute après le gestionnaire, y compris s'il a levé une exception. Ils s'exécutent pour chaque commande, route de composant et route de fenêtre modale du routeur : un hook susceptible de voir un composant ne doit donc pas supposer un Context. Les hooks « avant » du bot s'exécutent en premier et ses hooks « après » en dernier. Une exception levée par un hook « après » est consignée et ne change pas la réponse.

Hooks d'erreur

Une exception levée par une vérification, un convertisseur, un hook « avant » ou le gestionnaire va à un hook d'erreur. @x.on_error enregistre async def hook(ctx, error) pour toute Exception, et @x.on_error(NotFound, ValueError) pour ces types et leurs sous-classes. x est une commande, un groupe, une route de composant ou de fenêtre modale, un routeur ou le bot, et chacun peut avoir un hook par type d'exception.

bostie cherche un hook dans cet ordre, et le premier qu'il trouve traite l'erreur :

  1. la commande, puis son groupe et le groupe de premier niveau ;
  2. le routeur auquel appartient la commande, puis chaque routeur qui l'inclut, jusqu'au bot.

À un même endroit, le hook de la classe la plus proche de l'erreur l'emporte : un hook pour NotFound passe avant un hook pour HTTPException. Un hook qui relève la même erreur la transmet au hook suivant dans cet ordre, comme le fait slow_down ci-dessus. Un hook qui lève une autre erreur la voit consignée.

Le type du contexte est ErrorContext : un Context, ComponentContext, ModalContext ou AutocompleteContext. Le contexte d'autocomplétion ne peut pas appeler respond : testez-le donc comme ci-dessus.

Quand un hook se termine sans répondre, bostie consigne un avertissement et répond « Something went wrong. ». Quand aucun hook ne traite l'erreur, bostie répond à une CheckFailure avec son message, et consigne toute autre erreur avec sa trace d'appels avant de répondre « Something went wrong. ». Rien n'est répondu quand le gestionnaire avait déjà répondu, et une autocomplétion reçoit une liste de suggestions vide.

Erreurs hors des gestionnaires

Les écouteurs et les tâches n'ont pas d'utilisateur à qui répondre. Une exception dans un écouteur est envoyée sous forme d'événement ListenerError, et une exception dans une tâche sous forme de TaskError ; sans écouteur pour eux, bostie les consigne. Voir Événements et Tâches de fond.

Types d'exceptions

Tout ce que bostie lève au sujet de Discord, du réseau ou de l'état du bot est une BostieError. Un mauvais argument lève plutôt ValueError ou TypeError. Certaines erreurs sont aussi une exception standard : StateError est une RuntimeError, MissingIntent une ValueError, StartupError une OSError et CommandDefinitionError une TypeError. Les erreurs liées aux commandes et aux interactions partagent la base CommandError, et les erreurs vocales VoiceError.

Exception Levée quand
HTTPException une requête REST s'est terminée avec un statut d'erreur ; status, code (un JSONErrorCode), message, errors et le body brut
BadRequest, Forbidden, NotFound, ServerError 400, 403, 404 et 5xx
RateLimited 429, ou bostie a retenu une requête qui aurait attendu plus longtemps que max_rate_limit_wait ; retry_after, is_global
InvalidToken Discord a rejeté le token, en REST ou sur la gateway
TransportError une requête a échoué avant que Discord ne réponde
UnexpectedResponse Discord a accepté une requête mais sa réponse ne se décode pas ; raw la contient
RequestBudgetExhausted trop de requêtes récentes étaient invalides, et bostie en a refusé une pour protéger l'IP
GatewayClosed, PrivilegedIntentsRequired, ShardingRequired Discord a fermé la gateway pour de bon ; les deux dernières pour les codes 4014 et 4011
ShardNotRunning, GatewayQueueFull une commande de gateway pour un shard que ce processus n'exécute pas, ou alors que trop de commandes sont en attente
StateError un appel que l'état de l'objet ne permet pas, comme bot.rest avant start()
MissingIntent une fonctionnalité nécessite un intent que le bot n'a pas demandé
MemberRequestRateLimited Discord a refusé une requête de membres ; retry_after
StartupError bostie n'a pas pu mettre en place une ressource système dont il a besoin
CommandDefinitionError une commande, un groupe ou une option enfreint les règles de Discord ; levée par le décorateur
CommandSyncError Discord a rejeté les commandes pendant une synchronisation
OptionConversionError une option ou une cible n'a pas pu être convertie vers son annotation de type
InteractionResponded, InteractionNotAnswered, InteractionExpired un appel de réponse que l'état de l'interaction ne permet pas
CheckFailure et ses sous-classes une vérification a refusé une exécution

Les erreurs vocales figurent sur la page voix. La référence des erreurs liste chaque classe avec ses attributs.