Checks, hooks and errors¶

A check decides whether a handler may run. Hooks run code before and after handlers. Error hooks answer when something raises.
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)
A check is a function that takes the context and returns a bool, or awaits one. False refuses the run with CheckFailure, and a check can raise a CheckFailure of its own: its message is what the user is told. Checks go in the checks=[...] setting of a command, group, subcommand, user or message command, entry point, or component and modal route, and @router.check registers one for every handler of a router; the bot is a router.
The checks of the bot run first, then those of each router from the outermost in, then those of the groups from the top down, then the command's own. The first refusal stops the run. Autocomplete runs no checks.
bostie.checks has the common ones:
| Check | Refuses with | When |
|---|---|---|
has_permissions(permissions) |
MissingPermissions |
the user lacks any of permissions in the channel, or is in a DM |
bot_has_permissions(permissions) |
BotMissingPermissions |
the bot lacks any of them in the channel |
Cooldown(rate, seconds, per="user") |
CooldownActive |
the key already used it rate times in the last seconds |
MaxConcurrency(number, per="global") |
MaxConcurrencyReached |
number runs for the key are still going on |
has_role(role) |
MissingRole |
the user lacks the role, given by id or name, or is in a DM |
has_any_role(*roles) |
MissingRole |
the user has none of the roles |
is_owner |
NotOwner |
the user is not one of bot.owner_ids |
A check object that also has a release(ctx) method, a checks.ReleasingCheck like MaxConcurrency, is released once the handler ends, however it ends.
per is "user", "guild" (the user, in DMs), "channel", "global", or a function of the context that returns any hashable key. The permission checks read the permissions Discord sends with the interaction, so they need no cache and no request. Their errors carry missing, the Permissions lacking, and CooldownActive carries retry_after in seconds.
Roles and owners¶
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 and has_any_role take role ids or names. An id is matched against the roles Discord sends with the interaction, so it needs no cache. A name is looked up among the roles of the cached guild, so a bot without the cache, or an HTTP-only app, names roles by id. MissingRole carries the roles asked for, and its message mentions a role given by id, which Discord shows as the role's name.
is_owner is a check as it is, without parentheses. bot.owner_ids holds the users it lets through: the owner_ids given to Bot(...), else the app's owner, read from the application when the bot starts. For an app that belongs to a team, they are the team's owner and the admins and developers who accepted the invitation; read-only members are left out.
What the user is told¶
When no error hook handles a CheckFailure, bostie answers with its message, ephemeral: "You need the Manage Messages permission.", "The bot needs the Manage Messages permission.", "Try again" with a relative timestamp such as "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.", or "You cannot use this." for a plain False.
Hooks before and after¶
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 registers async def hook(ctx) that runs after the checks passed and before the handler; @router.after_invoke one that runs after the handler, also when it raised. They run for every command, component route and modal route of the router, so a hook that may see a component should not assume a Context. The bot's before hooks run first and its after hooks last. An exception from an after hook is logged and does not change the answer.
Error hooks¶
An exception from a check, a converter, a before hook or the handler goes to an error hook. @x.on_error registers async def hook(ctx, error) for every Exception, and @x.on_error(NotFound, ValueError) for those types and their subclasses. x is a command, a group, a component or modal route, a router or the bot, and each can have one hook per exception type.
bostie looks for a hook in this order, and the first it finds handles the error:
- the command, then its group and the top-level group;
- the router the command belongs to, then each router it is included in, up to the bot.
Within one place, the hook for the nearest class of the error wins: a hook for NotFound beats one for HTTPException. A hook that raises the same error passes it on to the next hook in that order, as slow_down does above. A hook that raises another error has it logged.
The context type is ErrorContext: a Context, ComponentContext, ModalContext or AutocompleteContext. The autocomplete context cannot respond, so check for it as above.
When a hook returns without answering, bostie logs a warning and answers "Something went wrong.". When no hook handles the error, bostie answers a CheckFailure with its message, and logs anything else with its traceback before it answers "Something went wrong.". Nothing is answered when the handler had answered already, and an autocomplete gets an empty list of suggestions.
Errors outside handlers¶
Listeners and tasks have no user to answer. An exception in a listener is sent as a ListenerError event, and one in a task as a TaskError; without a listener for them, bostie logs them. See Events and Background tasks.
Exception types¶
Everything bostie raises about Discord, the network or the bot's state is a BostieError. A bad argument raises ValueError or TypeError instead. Some errors are also a standard exception: StateError is a RuntimeError, MissingIntent a ValueError, StartupError an OSError and CommandDefinitionError a TypeError. The errors about commands and interactions share the base CommandError, and the voice errors VoiceError.
| Exception | Raised when |
|---|---|
HTTPException |
a REST request ended with an error status; status, code (a JSONErrorCode), message, errors and the raw body |
BadRequest, Forbidden, NotFound, ServerError |
400, 403, 404 and 5xx |
RateLimited |
429, or bostie held back a request that would wait longer than max_rate_limit_wait; retry_after, is_global |
InvalidToken |
Discord rejected the token, on REST or the gateway |
TransportError |
a request failed before Discord answered |
UnexpectedResponse |
Discord accepted a request but its answer does not decode; raw holds it |
RequestBudgetExhausted |
too many recent requests were invalid, and bostie refused one to protect the IP |
GatewayClosed, PrivilegedIntentsRequired, ShardingRequired |
Discord closed the gateway for good; the last two for codes 4014 and 4011 |
ShardNotRunning, GatewayQueueFull |
a gateway command for a shard this process does not run, or with too many commands waiting |
StateError |
a call the object's state does not allow, such as bot.rest before start() |
MissingIntent |
a feature needs an intent the bot did not ask for |
MemberRequestRateLimited |
Discord refused a member request; retry_after |
StartupError |
bostie could not set up a system resource it needs |
CommandDefinitionError |
a command, group or option breaks Discord's rules; raised by the decorator |
CommandSyncError |
Discord rejected the commands during a sync |
OptionConversionError |
an option or target did not convert to its hint |
InteractionResponded, InteractionNotAnswered, InteractionExpired |
an answering call the interaction's state does not allow |
CheckFailure and its subclasses |
a check refused a run |
The voice errors are on the voice page. The errors reference lists every class with its attributes.