Pular para conteúdo

Eventos

Bostie cumprimentando um sino que toca

O Discord conta ao bot o que acontece por meio de eventos do gateway: uma mensagem foi enviada, um membro entrou, um cargo mudou. Um listener é uma função assíncrona que recebe um 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 lê a anotação de tipo do primeiro parâmetro e registra a função para aquela classe de evento, ou para cada classe de uma união. @bot.listen(MessageUpdate) nomeia as classes no lugar disso, para uma função sem anotação ou com uma mais ampla. As classes ficam em bostie.events, e a lista de eventos nomeia cada uma com as suas intents e campos. Um router tem o mesmo decorador.

Cada listener roda como uma tarefa própria, então um listener lento não segura os outros nem o próximo evento. A tarefa começa na hora: o código de um listener até o seu primeiro await roda antes que o próximo evento seja tratado. Os listeners de Starting e Stopped são a exceção: start() e close() fazem await deles um depois do outro. bot.remove_listener(function) cancela o registro de uma função em todos os eventos que ela escuta.

Só o que você escuta

O bostie pede ao seu núcleo em Rust que encaminhe só os nomes de dispatch que algo no bot usa: os seus listeners, o cache, o roteamento de comandos e componentes e as esperas pendentes. Todos os outros eventos são descartados antes de chegar ao Python, então não escutar nada não custa nada. Os eventos tipados são decodificados uma vez por dispatch, e só quando alguém precisa deles.

Intents

O Discord só envia a maioria dos eventos a um bot que pediu as intents deles. Bot(intents=...) tem como padrão Intents.DEFAULT, todas as intents exceto as três privilegiadas. Quando um listener se registra para um evento que nenhuma das intents do bot entrega, o bostie escreve um aviso no log nomeando a intent a adicionar, e lembra que uma intent privilegiada também precisa ser ativada no Developer Portal. Intents as cobre em detalhe.

Valores antigos e o cache

Os eventos de atualização trazem old, o objeto como o cache o tinha antes da mudança, e os eventos de exclusão trazem o objeto removido: RoleUpdate.old, ScheduledEventUpdate.old, MessageDelete.message, MemberRemove.member. Eles são None quando o cache não tinha o objeto. As mensagens só ficam em cache com Bot(max_messages=...), os membros só com members=True e as presenças só com presences=True.

Esperando um 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) retorna o próximo evento daquela classe para o qual check retorna verdadeiro. async with bot.stream(Event, check=None) as events: enfileira cada evento correspondente enquanto o bloco roda, para async for; os eventos que chegam enquanto o corpo do laço roda não se perdem. Nenhum dos dois tem um tempo limite próprio, então limite-os com asyncio.timeout(). Uma exceção levantada por check é levantada pela espera. bot.wait_for_component e bot.wait_for_modal fazem o mesmo para usos de componentes e envios de modais.

Eventos brutos e eventos que o bostie não 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 um RawEvent para cada nome de dispatch que você passa, modelado ou não, e um listener anotado com RawEvent recebe todos os dispatches. Um RawEvent tem o name do dispatch, o seu número de sequência seq e o payload como raw (um dict novo a cada vez) ou raw_json (bytes). Todo evento tipado também tem raw e raw_json.

Quando o Discord envia um payload que não cabe na sua classe, por exemplo sem um campo que a documentação diz ser obrigatório, o bostie escreve um aviso no log uma vez por nome de evento, e os listeners tipados pulam esse evento enquanto os listeners de RawEvent ainda o recebem.

Eventos próprios

Crie uma subclasse de BotEvent, com kw_only=True, e envie uma instância com bot.dispatch(event): os listeners, as esperas e os streams dela a recebem como qualquer outro evento. Uma subclasse sem NAME recebe o nome do seu módulo e da sua classe. As classes de evento são structs msgspec imutáveis, então os campos delas são declarados como anotações.

Eventos de ciclo de vida e de shard

O bostie envia eventos próprios sobre o bot: Starting, Started, Stopping e Stopped em torno do ciclo de vida; ShardReady, ShardDisconnect, ShardStop e ShardBackpressure sobre cada shard; ListenerError e TaskError sobre falhas. As conexões de voz enviam eventos de voz pelo mesmo @bot.listen.

Quando um listener levanta uma exceção

Uma exceção em um listener é enviada como um evento ListenerError com o listener, o event e o error. Sem um listener de ListenerError, o bostie escreve a exceção no log com o traceback; com um, o logging fica por sua conta. Uma exceção em um listener de ListenerError só vai para o log.