Skip to content

Events and hooks

BallsDex gives your package two ways to react to what happens in the bot, without modifying its code:

  • Events are notifications. They are sent once an action has completed, run in the background, and cannot change anything. Use them for logging, statistics, quests, webhooks...
  • Hooks are awaited by the bot before an action happens. They run in order, and can block the action or modify its outcome. Use them for custom rules, anti-cheat, boosts...
Events Hooks
Declared with @commands.Cog.listener() @hook("name")
Awaited by the bot No Yes, one after another
Can block the action No pre_* and spawn_check hooks
Can modify the outcome No catch_roll and catch_message hooks
Ordering None By priority

There are no hooks running after an action: once it's done, use events. A slow listener will never delay the bot.

Both examples below assume you already have a discord.py extension with a cog.

Events

Events are dispatched with discord.py's own event system, so you listen to them like any other discord.py event: add on_ in front of the event name.

Listener Payload When
on_ballsdex_ball_spawned BallSpawnedEvent A countryball spawned (natural spawn, admin spawn or drop)
on_ballsdex_ball_caught BallCaughtEvent A spawned countryball was caught
on_ballsdex_trade_completed TradeCompletedEvent A trade was confirmed and saved
on_ballsdex_ball_given BallGivenEvent Countryballs were given or a donation was accepted
on_ballsdex_settings_change guild, channel=None, enabled=None A server's spawn settings changed

Note

ballsdex_settings_change predates the other events and uses positional arguments instead of a payload object.

Payloads are read-only. An exception raised in a listener is logged and does not affect the bot.

Announcing special catches

from discord.ext import commands

from ballsdex.core.events import BallCaughtEvent

LOG_CHANNEL_ID = 1234567890


class Announcer(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @commands.Cog.listener()
    async def on_ballsdex_ball_caught(self, event: BallCaughtEvent):
        special = event.ball_instance.specialcard
        if special is None or event.dropped:
            return
        channel = self.bot.get_channel(LOG_CHANNEL_ID)
        if channel:
            await channel.send(
                f"{event.user.mention} just caught a **{special.name}** "
                f"{event.ball_instance.countryball.country}!"
            )

Warning

You are in an async context, so accessing a foreign key such as ball_instance.special or ball_instance.ball would raise SynchronousOnlyOperation. Use the cached properties specialcard and countryball instead, or fetch the related objects with an async query.

Tracking trades

The trade payload does not contain the countryballs exchanged, query them from the trade entry:

from discord.ext import commands

from ballsdex.core.events import TradeCompletedEvent
from bd_models.models import TradeObject


class TradeStats(commands.Cog):
    def __init__(self, bot):
        self.bot = bot
        self.traded = 0

    @commands.Cog.listener()
    async def on_ballsdex_trade_completed(self, event: TradeCompletedEvent):
        self.traded += await TradeObject.objects.filter(trade=event.trade).acount()

Reacting to spawns

from discord.ext import commands

from ballsdex.core.events import BallSpawnedEvent


class SpawnReactions(commands.Cog):
    @commands.Cog.listener()
    async def on_ballsdex_ball_spawned(self, event: BallSpawnedEvent):
        if event.ball.rarity < 0.1:
            await event.message.add_reaction("\N{EYES}")

Hooks

A hook is an async function receiving a context object. Decorate a cog method with hook, and it is registered when the cog is added to the bot, then unregistered when the cog is removed (including on reload).

Hook Context When Can
spawn_check SpawnCheckContext Before a natural spawn Cancel
pre_catch PreCatchContext After a correct guess, before catching Cancel
catch_roll CatchRollContext Before a new countryball is created Edit special, attack_bonus, health_bonus
catch_message CatchMessageContext Before the catch message is sent Edit content
pre_trade PreTradeContext Both users confirmed, before saving Cancel
pre_give PreGiveContext Before a give or donation request Cancel

A few rules to keep in mind:

  • Hooks run one after another, highest priority first (default 0).
  • Calling ctx.cancel(reason) blocks the action and skips the remaining hooks. The reason is shown to the user, a generic message is used if you omit it.
  • A hook raising an exception, or taking longer than 3 seconds, is logged and skipped. The action continues as if the hook wasn't there.
  • The user is waiting on your hook: keep it fast, and move slow work to an event listener.

Catch cooldown

import time

from discord.ext import commands

from ballsdex.core.hooks import PreCatchContext, hook

COOLDOWN = 60


class CatchCooldown(commands.Cog):
    def __init__(self):
        self.last_catch: dict[int, float] = {}

    @hook("pre_catch")
    async def cooldown(self, ctx: PreCatchContext):
        now = time.monotonic()
        last = self.last_catch.get(ctx.player.discord_id, 0)
        if now - last < COOLDOWN:
            ctx.cancel(f"You can only catch once every {COOLDOWN} seconds.")
            return
        self.last_catch[ctx.player.discord_id] = now

Quiet hours

from datetime import datetime, timezone

from discord.ext import commands

from ballsdex.core.hooks import SpawnCheckContext, hook


class QuietHours(commands.Cog):
    @hook("spawn_check")
    async def no_night_spawns(self, ctx: SpawnCheckContext):
        if 2 <= datetime.now(timezone.utc).hour < 6:
            ctx.cancel()

Weekend stat boost

from datetime import datetime

from discord.ext import commands

from ballsdex.core.hooks import CatchRollContext, hook
from settings.models import settings


class WeekendBoost(commands.Cog):
    @hook("catch_roll")
    async def boost(self, ctx: CatchRollContext):
        if datetime.now().weekday() >= 5:
            ctx.attack_bonus = min(ctx.attack_bonus + 5, settings.max_attack_bonus)
            ctx.health_bonus = min(ctx.health_bonus + 5, settings.max_health_bonus)

Extra catch message and reward

Players earn money for a new countryball, and the catch message tells them about it:

from discord.ext import commands

from ballsdex.core.hooks import CatchMessageContext, hook
from settings.models import settings

REWARD = 50


class CatchRewards(commands.Cog):
    @hook("catch_message")
    async def reward(self, ctx: CatchMessageContext):
        if not ctx.is_new:
            return
        await ctx.player.add_money(REWARD)
        ctx.content += f"\nYou earned {REWARD} {settings.currency_name} for this new catch!"

Blocking trades of fresh catches

from datetime import timedelta

from django.utils import timezone
from discord.ext import commands

from ballsdex.core.hooks import PreTradeContext, hook
from bd_models.models import BallInstance


class TradeRules(commands.Cog):
    @hook("pre_trade")
    async def no_fresh_catches(self, ctx: PreTradeContext):
        ids = ctx.trader1.proposal | ctx.trader2.proposal
        recent = timezone.now() - timedelta(hours=1)
        if await BallInstance.objects.filter(id__in=ids, catch_date__gt=recent).aexists():
            ctx.cancel("Countryballs caught less than an hour ago cannot be traded.")

Daily donation limit

Hooks and events pair well together: the pre_give hook blocks donations over the limit, while a listener counts what was actually given.

from collections import Counter

from discord.ext import commands, tasks

from ballsdex.core.events import BallGivenEvent
from ballsdex.core.hooks import PreGiveContext, hook

DAILY_LIMIT = 20


class DonationLimit(commands.Cog):
    def __init__(self):
        self.given: Counter[int] = Counter()
        self.reset.start()

    async def cog_unload(self):
        self.reset.cancel()

    @tasks.loop(hours=24)
    async def reset(self):
        self.given.clear()

    @hook("pre_give")
    async def check_limit(self, ctx: PreGiveContext):
        if self.given[ctx.sender.pk] >= DAILY_LIMIT:
            ctx.cancel(f"You can only give {DAILY_LIMIT} countryballs per day.")

    @commands.Cog.listener()
    async def on_ballsdex_ball_given(self, event: BallGivenEvent):
        self.given[event.sender.pk] += len(event.ball_instances)

Registering without a cog

Hooks can also be registered manually on bot.hooks, a HookRegistry. In that case, you are responsible for unregistering them.

from ballsdex.core.hooks import SpawnCheckContext

BLOCKED_GUILDS = {1234567890}


async def block_guilds(ctx: SpawnCheckContext):
    if ctx.guild.id in BLOCKED_GUILDS:
        ctx.cancel()


async def setup(bot):
    bot.hooks.register("spawn_check", block_guilds, priority=5)


async def teardown(bot):
    bot.hooks.unregister("spawn_check", block_guilds)