콘텐츠로 이동

이벤트

울리는 종에게 인사하는 Bostie

Discord는 게이트웨이 이벤트로 봇에게 무슨 일이 일어났는지 알려 줍니다. 메시지가 전송되었다거나, 멤버가 들어왔다거나, 역할이 바뀌었다는 식입니다. 리스너는 이벤트 하나를 받는 비동기 함수입니다.

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은 첫 번째 매개변수의 타입 힌트를 읽고, 그 이벤트 클래스에, 또는 유니언이면 유니언의 각 클래스에 함수를 등록합니다. 힌트가 없거나 더 넓은 힌트를 가진 함수라면 @bot.listen(MessageUpdate)처럼 클래스를 직접 지정합니다. 이벤트 클래스는 bostie.events에 있으며, 이벤트 목록에 모든 클래스가 인텐트, 필드와 함께 정리되어 있습니다. 라우터에도 같은 데코레이터가 있습니다.

각 리스너는 자기만의 태스크로 실행되므로, 느린 리스너가 다른 리스너나 다음 이벤트를 붙잡아 두지 않습니다. 태스크는 바로 시작됩니다. 리스너 코드 중 첫 번째 await까지는 다음 이벤트를 처리하기 전에 실행됩니다. 예외는 Starting과 Stopped의 리스너로, start()와 close()가 이들을 하나씩 차례로 await합니다. bot.remove_listener(function)은 함수가 듣고 있는 모든 이벤트에서 그 함수의 등록을 해제합니다.

듣는 것만 받기

bostie는 봇 안의 무언가가 사용하는 디스패치 이름만 전달하도록 Rust 코어에 요청합니다. 여기에는 리스너, 캐시, 명령어와 컴포넌트 라우팅, 대기 중인 wait가 포함됩니다. 나머지 이벤트는 Python에 닿기 전에 버려지므로, 아무것도 듣지 않으면 비용도 들지 않습니다. 타입이 지정된 이벤트는 디스패치마다 한 번만, 그리고 필요한 곳이 있을 때만 디코딩됩니다.

인텐트

Discord는 대부분의 이벤트를 해당 인텐트를 요청한 봇에게만 보냅니다. Bot(intents=...)의 기본값은 Intents.DEFAULT로, 특권 인텐트 세 개를 뺀 모든 인텐트입니다. 봇의 인텐트로는 받을 수 없는 이벤트에 리스너를 등록하면, bostie는 추가해야 할 인텐트를 알려 주는 경고를 로그에 남기고, 특권 인텐트라면 Developer Portal에서도 켜야 한다는 점을 함께 알려 줍니다. 자세한 내용은 인텐트에서 다룹니다.

이전 값과 캐시

업데이트 이벤트는 변경 전 캐시에 있던 객체인 old를 담고, 삭제 이벤트는 삭제된 객체를 담습니다. RoleUpdate.old, ScheduledEventUpdate.old, MessageDelete.message, MemberRemove.member가 그 예입니다. 캐시에 객체가 없었으면 이 값은 None입니다. 메시지는 Bot(max_messages=...)를 지정했을 때만, 멤버는 members=True일 때만, 프레즌스는 presences=True일 때만 캐시됩니다.

이벤트 기다리기

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)은 check가 참을 반환하는, 해당 클래스의 다음 이벤트를 반환합니다. async with bot.stream(Event, check=None) as events:는 블록이 실행되는 동안 조건에 맞는 모든 이벤트를 큐에 쌓아 async for로 받을 수 있게 하며, 루프 본문이 실행되는 동안 도착한 이벤트도 잃어버리지 않습니다. 둘 다 자체 타임아웃이 없으므로 asyncio.timeout()으로 제한하세요. check에서 발생한 예외는 wait에서 그대로 발생합니다. bot.wait_for_component와 bot.wait_for_modal은 컴포넌트 사용과 모달 제출에 대해 같은 일을 합니다.

원시 이벤트와 bostie가 모델링하지 않는 이벤트

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", ...)은 지정한 디스패치 이름마다, 모델링된 것이든 아니든 RawEvent를 전달하며, RawEvent 힌트를 가진 리스너는 모든 디스패치를 받습니다. RawEvent에는 디스패치 이름 name, 시퀀스 번호 seq, 그리고 페이로드가 raw(매번 새 dict)나 raw_json(바이트)으로 들어 있습니다. 타입이 지정된 모든 이벤트에도 raw와 raw_json이 있습니다.

Discord가 보낸 페이로드가 해당 클래스에 맞지 않으면, 예를 들어 문서에서 필수라고 한 필드가 없으면, bostie는 이벤트 이름마다 한 번 경고를 로그에 남깁니다. 타입이 지정된 리스너는 그 이벤트를 건너뛰지만, RawEvent 리스너는 여전히 받습니다.

직접 정의한 이벤트

kw_only=True로 BotEvent를 상속하고, bot.dispatch(event)로 인스턴스를 보냅니다. 그러면 리스너, wait, 스트림이 다른 이벤트와 똑같이 그 이벤트를 받습니다. NAME이 없는 하위 클래스는 모듈과 클래스 이름을 따라 이름이 정해집니다. 이벤트 클래스는 frozen msgspec 구조체이므로, 필드는 어노테이션으로 선언합니다.

수명 주기 이벤트와 샤드 이벤트

bostie는 봇에 관한 자체 이벤트를 보냅니다. 수명 주기 전후에는 Starting, Started, Stopping, Stopped를, 각 샤드에 대해서는 ShardReady, ShardDisconnect, ShardStop, ShardBackpressure를, 실패에 대해서는 ListenerError와 TaskError를 보냅니다. 음성 연결은 같은 @bot.listen으로 음성 이벤트를 보냅니다.

리스너가 예외를 일으킬 때

리스너에서 발생한 예외는 listener, event, error를 담은 ListenerError 이벤트로 전송됩니다. ListenerError 리스너가 없으면 bostie가 트레이스백과 함께 예외를 로그에 남기고, 리스너가 있으면 로깅은 직접 처리해야 합니다. ListenerError 리스너에서 발생한 예외는 로그에만 남습니다.