コンテンツにスキップ

イベント

鳴っているベルにあいさつする Bostie

Discord は、ゲートウェイイベントを通じて、何が起きたかを Bot に伝えます。メッセージが送信された、メンバーが参加した、ロールが変更された、といったことです。リスナーは、イベントを 1 つ受け取る非同期関数です。

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() がこれらを 1 つずつ順に await します。bot.remove_listener(function) は、関数が待ち受けているすべてのイベントからその関数の登録を解除します。

待ち受けるものだけ

bostie は Rust コアに対し、Bot の中の何かが使っているディスパッチ名だけを転送するよう求めます。対象は、あなたのリスナー、キャッシュ、コマンドとコンポーネントのルーティング、そして待機中の処理です。それ以外のイベントはすべて Python に届く前に破棄されるので、何も待ち受けなければ何のコストもかかりません。型付きのイベントは、ディスパッチごとに一度だけ、しかも必要とされるときにだけデコードされます。

インテント

Discord はほとんどのイベントを、そのインテントを要求した Bot にしか送りません。Bot(intents=...) の既定値は Intents.DEFAULT で、3 つの特権インテントを除くすべてのインテントです。Bot のどのインテントでも届かないイベントに対してリスナーを登録すると、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 が送出した例外は、待機の側から送出されます。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) で送ります。そのリスナー、待機、ストリームは、ほかのイベントと同じようにそれを受け取ります。NAME を持たないサブクラスには、モジュール名とクラス名に基づいた名前が付きます。イベントクラスはフリーズされた msgspec の構造体なので、フィールドはアノテーションとして宣言します。

ライフサイクルとシャードのイベント

bostie は Bot 自身についての独自のイベントを送ります。ライフサイクルの前後には Starting、Started、Stopping、Stopped、各シャードについては ShardReady、ShardDisconnect、ShardStop、ShardBackpressure、失敗については ListenerError と TaskError です。ボイス接続は、同じ @bot.listen を通じてボイスのイベントを送ります。

リスナーが例外を送出したとき

リスナー内の例外は、listener、event、error を持つ ListenerError イベントとして送られます。ListenerError のリスナーがなければ、bostie はその例外をトレースバック付きでログに出力します。リスナーがある場合、ログをどう出力するかはあなた次第です。ListenerError のリスナー内で起きた例外は、ログに出力されるだけです。