Saltar a contenido

Eventos

Bostie saluda a una campana que suena

Discord le cuenta al bot lo que pasa mediante eventos del gateway: se envió un mensaje, se unió un miembro, cambió un rol. Un listener es una función asíncrona que recibe un evento.

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 lee la anotación de tipo del primer parámetro y registra la función para esa clase de evento, o para cada clase de una unión. @bot.listen(MessageUpdate) indica las clases en su lugar, para una función sin anotación o con una más amplia. Las clases están en bostie.events, y la lista de eventos nombra cada una con sus intents y sus campos. Un router tiene el mismo decorador.

Cada listener se ejecuta como su propia tarea, así que un listener lento no frena a los demás ni al evento siguiente. La tarea empieza de inmediato: el código de un listener hasta su primer await se ejecuta antes de que se maneje el evento siguiente. Los listeners de Starting y Stopped son la excepción: start() y close() los esperan uno después del otro. bot.remove_listener(function) quita el registro de una función de todos los eventos que escucha.

Solo lo que escuchas

bostie le pide a su núcleo en Rust que reenvíe solo los nombres de dispatch que usa algo del bot: tus listeners, la caché, el enrutamiento de comandos y componentes, y las esperas pendientes. Cualquier otro evento se descarta antes de llegar a Python, así que no escuchar nada no cuesta nada. Los eventos tipados se decodifican una vez por dispatch, y solo cuando alguien los necesita.

Intents

Discord envía la mayoría de los eventos solo a un bot que pidió sus intents. Bot(intents=...) vale Intents.DEFAULT por defecto, todos los intents salvo los tres privilegiados. Cuando un listener se registra para un evento que no llega con ninguno de los intents del bot, bostie deja una advertencia en el log con el intent que hay que agregar, y te recuerda que uno privilegiado también hay que activarlo en el Developer Portal. Intents los cubre en detalle.

Valores anteriores y la caché

Los eventos de actualización traen old, el objeto tal como lo tenía la caché antes del cambio, y los eventos de eliminación traen el objeto eliminado: RoleUpdate.old, ScheduledEventUpdate.old, MessageDelete.message, MemberRemove.member. Son None cuando la caché no tenía el objeto. Los mensajes solo se guardan en caché con Bot(max_messages=...), los miembros solo con members=True y las presencias solo con presences=True.

Esperar un evento

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) devuelve el siguiente evento de esa clase para el que check devuelva verdadero. async with bot.stream(Event, check=None) as events: pone en cola cada evento que coincide mientras se ejecuta el bloque, para usarlo con async for; los eventos que llegan mientras se ejecuta el cuerpo del bucle no se pierden. Ninguno de los dos tiene un tiempo límite propio, así que ponles un límite con asyncio.timeout(). Una excepción lanzada por check se lanza desde la espera. bot.wait_for_component y bot.wait_for_modal hacen lo mismo para los usos de componentes y los envíos de modales.

Eventos sin procesar y eventos que bostie no modela

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", ...) entrega un RawEvent por cada nombre de dispatch que indiques, esté modelado o no, y un listener anotado con RawEvent recibe todos los dispatch. Un RawEvent tiene el name del dispatch, su número de secuencia seq y el payload como raw (un dict nuevo cada vez) o raw_json (bytes). Todos los eventos tipados también tienen raw y raw_json.

Cuando Discord envía un payload que no encaja con su clase, por ejemplo sin un campo que la documentación marca como obligatorio, bostie deja una advertencia en el log una vez por nombre de evento, y los listeners tipados se saltan ese evento mientras que los listeners de RawEvent lo siguen recibiendo.

Eventos propios

Crea una subclase de BotEvent, con kw_only=True, y envía una instancia con bot.dispatch(event): sus listeners, esperas y streams la reciben como cualquier otro evento. Una subclase sin NAME se nombra según su módulo y su clase. Las clases de eventos son structs inmutables de msgspec, así que sus campos se declaran como anotaciones.

Eventos del ciclo de vida y de los shards

bostie envía eventos propios sobre el bot: Starting, Started, Stopping y Stopped alrededor del ciclo de vida; ShardReady, ShardDisconnect, ShardStop y ShardBackpressure sobre cada shard; ListenerError y TaskError sobre los fallos. Las conexiones de voz envían eventos de voz por el mismo @bot.listen.

Cuando un listener lanza una excepción

Una excepción en un listener se envía como un evento ListenerError con el listener, el event y el error. Sin un listener de ListenerError, bostie registra la excepción en el log con su traceback; si hay uno, el logging queda a tu cargo. Una excepción en un listener de ListenerError solo se registra en el log.