Skip to content

Events

Bostie greeting a ringing bell

Discord tells the bot what happens through gateway events: a message was sent, a member joined, a role changed. A listener is an async function that takes one event.

import logging

import bostie
from bostie import Intents
from bostie.events import (
    MemberJoin,
    MemberRemove,
    MessageDelete,
    MessageUpdate,
    RoleUpdate,
)

log = logging.getLogger("my-bot")
bot = bostie.Bot(intents=Intents.DEFAULT | Intents.GUILD_MEMBERS, max_messages=1000)


@bot.listen
async def membership(event: MemberJoin | MemberRemove) -> None:
    if isinstance(event, MemberJoin):
        log.info("%s joined guild %d", event.member.user.username, event.guild_id)
    else:
        log.info("%s left guild %d", event.user.username, event.guild_id)


@bot.listen
async def role_renamed(event: RoleUpdate) -> None:
    if event.old is not None and event.old.name != event.role.name:
        log.info("role %r is now %r", event.old.name, event.role.name)


@bot.listen(MessageUpdate)
async def edited(event: MessageUpdate) -> None:
    if event.old is not None:
        log.info("edited: %r -> %r", event.old.content, event.message.content)


@bot.listen
async def deleted(event: MessageDelete) -> None:
    if event.message is not None:
        log.info("deleted: %r", event.message.content)

@bot.listen reads the type hint of the first parameter and registers the function for that event class, or for each class of a union. @bot.listen(MessageUpdate) names the classes instead, for a function without a hint or with a broader one. The classes live in bostie.events, and the event list names every one with its intents and fields. A router has the same decorator.

Each listener runs as its own task, so a slow listener does not hold up the others or the next event. The task starts at once: a listener's code up to its first await runs before the next event is handled. The listeners of Starting and Stopped are the exception: start() and close() await them one after the other. bot.remove_listener(function) unregisters a function from every event it listens to.

Only what you listen to

bostie asks its Rust core to forward only the dispatch names that something in the bot uses: your listeners, the cache, command and component routing, and pending waits. Every other event is dropped before it reaches Python, so listening to nothing costs nothing. Typed events are decoded once per dispatch, and only when someone needs them.

Intents

Discord sends most events only to a bot that asked for their intents. Bot(intents=...) defaults to Intents.DEFAULT, every intent except the three privileged ones. When a listener registers for an event none of the bot's intents delivers, bostie logs a warning naming the intent to add, and reminds you that a privileged one also has to be turned on in the Developer Portal. Intents covers them in detail.

Old values and the cache

Update events carry old, the object as the cache had it before the change, and delete events carry the removed object: RoleUpdate.old, ScheduledEventUpdate.old, MessageDelete.message, MemberRemove.member. They are None when the cache did not hold the object. Messages are cached only with Bot(max_messages=...), members only with members=True, presences only with presences=True.

Waiting for an event

import asyncio

import bostie
from bostie import Context
from bostie.events import ReactionAdd

bot = bostie.Bot()


@bot.command
async def vote(ctx: Context, question: str) -> None:
    """Count thumbs-up reactions for 60 seconds."""
    message = await ctx.respond(question)
    me = bot.user.id if bot.user is not None else 0
    voters: set[int] = set()

    def counts(event: ReactionAdd) -> bool:
        return (
            event.message_id == message.id
            and event.emoji.name == "\N{THUMBS UP SIGN}"
            and event.user_id != me
        )

    async with bot.stream(ReactionAdd, check=counts) as reactions:
        await bot.rest.reactions.create(
            message.channel_id, message.id, "\N{THUMBS UP SIGN}"
        )
        try:
            async with asyncio.timeout(60):
                async for reaction in reactions:
                    voters.add(reaction.user_id)
        except TimeoutError:
            pass
    await ctx.followup(f"{len(voters)} votes.")

await bot.wait_for(Event, check=None) returns the next event of that class for which check returns true. async with bot.stream(Event, check=None) as events: queues every matching event while the block runs, for async for; events that arrive while the loop body runs are not lost. Neither has a timeout of its own, so bound them with asyncio.timeout(). An exception raised by check is raised from the wait. bot.wait_for_component and bot.wait_for_modal do the same for component uses and modal submits.

Raw events and events bostie does not model

import logging

import bostie
from bostie import Context
from bostie.events import BotEvent, ListenerError, RawEvent

log = logging.getLogger("my-bot")
bot = bostie.Bot()


class LevelUp(BotEvent, kw_only=True):
    user_id: int
    level: int


@bot.command
async def train(ctx: Context) -> None:
    """Gain a level."""
    bot.dispatch(LevelUp(user_id=ctx.user.id, level=2))
    await ctx.respond("You trained hard.")


@bot.listen
async def congratulate(event: LevelUp) -> None:
    log.info("user %d reached level %d", event.user_id, event.level)


@bot.listen_raw("GUILD_AUDIT_LOG_ENTRY_CREATE", "SOME_FUTURE_EVENT")
async def raw(event: RawEvent) -> None:
    log.info("%s, sequence %d: %r", event.name, event.seq, event.raw)


@bot.listen
async def failed(failure: ListenerError) -> None:
    log.error(
        "%s failed on %r", failure.listener, failure.event, exc_info=failure.error
    )

@bot.listen_raw("NAME", ...) delivers a RawEvent for each dispatch name you give, modelled or not, and a listener hinted with RawEvent gets every dispatch. A RawEvent has the dispatch name, its sequence number seq, and the payload as raw (a new dict each time) or raw_json (bytes). Every typed event has raw and raw_json too.

When Discord sends a payload that does not fit its class, for example without a field the docs call required, bostie logs a warning once per event name, and typed listeners skip that event while RawEvent listeners still get it.

Events of your own

Subclass BotEvent, with kw_only=True, and send an instance with bot.dispatch(event): its listeners, waits and streams get it like any other event. A subclass without a NAME is named after its module and class. Event classes are frozen msgspec structs, so their fields are declared as annotations.

Lifecycle and shard events

bostie sends events of its own about the bot: Starting, Started, Stopping and Stopped around the lifecycle; ShardReady, ShardDisconnect, ShardStop and ShardBackpressure about each shard; ListenerError and TaskError about failures. Voice connections send voice events through the same @bot.listen.

When a listener raises

An exception in a listener is sent as a ListenerError event with the listener, the event and the error. Without a ListenerError listener, bostie logs the exception with its traceback; with one, logging is up to you. An exception in a ListenerError listener is only logged.