From 33e9f5e917ec105f88ae6ac5aa8622e39b8f9f87 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Mon, 25 May 2026 23:33:13 +0200 Subject: [PATCH 1/5] Add dog command with random picture feature --- prod-config.toml | 16 ++++++- src/europython_discord/bot.py | 4 ++ src/europython_discord/dog/__init__.py | 0 src/europython_discord/dog/cog.py | 37 +++++++++++++++ src/europython_discord/dog/config.py | 5 ++ src/europython_discord/dog/dogclient.py | 28 ++++++++++++ test-config.toml | 15 +++++- tests/dog/__init__.py | 0 tests/dog/test_cog.py | 61 +++++++++++++++++++++++++ 9 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 src/europython_discord/dog/__init__.py create mode 100644 src/europython_discord/dog/cog.py create mode 100644 src/europython_discord/dog/config.py create mode 100644 src/europython_discord/dog/dogclient.py create mode 100644 tests/dog/__init__.py create mode 100644 tests/dog/test_cog.py diff --git a/prod-config.toml b/prod-config.toml index b138e6be..63d75224 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -27,7 +27,12 @@ pretix_cache_file = "pretix_cache.json" # speakers "Presenter" = ["Participants", "Onsite Participants", "Speakers"] # onsite volunteers -"On-site Volunteer" = ["Participants", "Onsite Participants", "Volunteers", "Onsite Volunteers"] +"On-site Volunteer" = [ + "Participants", + "Onsite Participants", + "Volunteers", + "Onsite Volunteers", +] # beginners' day "Beginnersโ€™ Day Unconference / Humble Data Access" = ["Beginners Day"] @@ -60,3 +65,12 @@ main_notification_channel_name = "programme-notifications" [guild_statistics] required_role = "Organizers" + +[dog] +# this section is optional; defaults are used if omitted +error_messages = [ + "The dogs are on strike today! Try again later. ๐Ÿพ๐Ÿชง", + "A wild error appeared! The dog got away... ๐Ÿ•๐Ÿ’จ", + "Dog API is fetching a stick. Throw it again! ๐Ÿฆด", + "404: Dog not found. Have you checked under the couch? ๐Ÿ›‹๏ธ", +] diff --git a/src/europython_discord/bot.py b/src/europython_discord/bot.py index be7e172c..056d5c2e 100644 --- a/src/europython_discord/bot.py +++ b/src/europython_discord/bot.py @@ -15,6 +15,8 @@ from europython_discord.cogs.guild_statistics import GuildStatisticsCog, GuildStatisticsConfig from europython_discord.cogs.ping import PingCog +from europython_discord.dog.cog import DogCog +from europython_discord.dog.config import DogConfig from europython_discord.program_notifications.cog import ProgramNotificationsCog from europython_discord.program_notifications.config import ProgramNotificationsConfig from europython_discord.registration.cog import RegistrationCog @@ -32,6 +34,7 @@ class Config(BaseModel): registration: RegistrationConfig program_notifications: ProgramNotificationsConfig guild_statistics: GuildStatisticsConfig + dog: DogConfig = DogConfig() async def run_bot(config: Config, auth_token: str) -> None: @@ -44,6 +47,7 @@ async def run_bot(config: Config, auth_token: str) -> None: async with commands.Bot(intents=intents, command_prefix="$") as bot: await bot.add_cog(PingCog(bot)) + await bot.add_cog(DogCog(bot, config.dog)) await bot.add_cog(RegistrationCog(bot, config.registration)) await bot.add_cog(ProgramNotificationsCog(bot, config.program_notifications)) await bot.add_cog(GuildStatisticsCog(bot, config.guild_statistics)) diff --git a/src/europython_discord/dog/__init__.py b/src/europython_discord/dog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/europython_discord/dog/cog.py b/src/europython_discord/dog/cog.py new file mode 100644 index 00000000..9c6b0330 --- /dev/null +++ b/src/europython_discord/dog/cog.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import logging +import random + +import discord +from discord.ext import commands + +from europython_discord.dog.config import DogConfig +from europython_discord.dog.dogclient import DogClient + +_logger = logging.getLogger(__name__) + + +class DogCog(commands.Cog): + def __init__( + self, + bot: commands.Bot, + config: DogConfig, + client: DogClient | None = None, + ) -> None: + self.bot = bot + self.config = config + self._client = client or DogClient() + _logger.info("Cog 'Dog' has been initialized") + + @commands.hybrid_command(name="dog", description="Get a random dog picture") + async def dog_command(self, ctx: commands.Context) -> None: + image_url = await self._client.fetch_random_dog() + if image_url is None: + message = random.choice(self.config.error_messages) # noqa: S311 + await ctx.send(message) + return + + embed = discord.Embed() + embed.set_image(url=image_url) + await ctx.send(embed=embed) diff --git a/src/europython_discord/dog/config.py b/src/europython_discord/dog/config.py new file mode 100644 index 00000000..0611979e --- /dev/null +++ b/src/europython_discord/dog/config.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class DogConfig(BaseModel): + error_messages: list[str] = ["404: Dog not found. Have you checked under the couch? ๐Ÿ›‹๏ธ"] diff --git a/src/europython_discord/dog/dogclient.py b/src/europython_discord/dog/dogclient.py new file mode 100644 index 00000000..85e5727c --- /dev/null +++ b/src/europython_discord/dog/dogclient.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import logging + +import aiohttp + +_logger = logging.getLogger(__name__) + +DOG_API_URL = "https://dog.ceo/api/breeds/image/random" + + +class DogClient: + def __init__(self) -> None: + self._session = aiohttp.ClientSession() + + async def close(self) -> None: + await self._session.close() + + async def fetch_random_dog(self) -> str | None: + try: + async with self._session.get(DOG_API_URL) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch dog image") + return None + + return data["message"] diff --git a/test-config.toml b/test-config.toml index b84bd692..4f43ca67 100644 --- a/test-config.toml +++ b/test-config.toml @@ -27,7 +27,12 @@ pretix_cache_file = "pretix_cache.json" # speakers "Presenter" = ["Participants", "Onsite Participants", "Speakers"] # onsite volunteers -"On-site Volunteer" = ["Participants", "Onsite Participants", "Volunteers", "Onsite Volunteers"] +"On-site Volunteer" = [ + "Participants", + "Onsite Participants", + "Volunteers", + "Onsite Volunteers", +] # beginners' day "Beginnersโ€™ Day Unconference / Humble Data Access" = ["Beginners Day"] @@ -60,3 +65,11 @@ fast_mode = true [guild_statistics] required_role = "Organizers" + +[dog] +error_messages = [ + "The dogs are on strike today! Try again later. ๐Ÿพ๐Ÿชง", + "A wild error appeared! The dog got away... ๐Ÿ•๐Ÿ’จ", + "Dog API is fetching a stick. Throw it again! ๐Ÿฆด", + "404: Dog not found. Have you checked under the couch? ๐Ÿ›‹๏ธ", +] diff --git a/tests/dog/__init__.py b/tests/dog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/dog/test_cog.py b/tests/dog/test_cog.py new file mode 100644 index 00000000..b76adc15 --- /dev/null +++ b/tests/dog/test_cog.py @@ -0,0 +1,61 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from discord.ext import commands + +from europython_discord.dog.cog import DogCog +from europython_discord.dog.config import DogConfig +from europython_discord.dog.dogclient import DogClient + + +@pytest.fixture +def config() -> DogConfig: + return DogConfig() + + +@pytest.fixture +def bot() -> MagicMock: + return MagicMock(spec=commands.Bot) + + +@pytest.fixture +def dog_url() -> str: + return "https://images.dog.ceo/dog.jpg" + + +@pytest.fixture +def mock_client(dog_url: str) -> DogClient: + client = MagicMock(spec=DogClient) + client.fetch_random_dog.return_value = dog_url + return client + + +@pytest.fixture +def cog(bot: MagicMock, config: DogConfig, mock_client: DogClient) -> DogCog: + return DogCog(bot, config, client=mock_client) + + +@pytest.fixture +def ctx() -> AsyncMock: + mock = AsyncMock(spec=commands.Context) + mock.send = AsyncMock() + return mock + + +async def test_dog_command_success(cog: DogCog, ctx: AsyncMock, dog_url: str) -> None: + await cog.dog_command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + embed = ctx.send.call_args.kwargs["embed"] + assert embed.image.url == dog_url + + +async def test_dog_command_api_error(cog: DogCog, ctx: AsyncMock, mock_client: DogClient) -> None: + mock_client.fetch_random_dog.return_value = None + + await cog.dog_command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + text = ctx.send.call_args.args[0] + + assert text in cog.config.error_messages From 7a05347a1928a24fe61517bee29d7452f8ea099f Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sun, 21 Jun 2026 14:15:15 +0200 Subject: [PATCH 2/5] Remove close method --- src/europython_discord/dog/dogclient.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/europython_discord/dog/dogclient.py b/src/europython_discord/dog/dogclient.py index 85e5727c..80c7dcbb 100644 --- a/src/europython_discord/dog/dogclient.py +++ b/src/europython_discord/dog/dogclient.py @@ -13,9 +13,6 @@ class DogClient: def __init__(self) -> None: self._session = aiohttp.ClientSession() - async def close(self) -> None: - await self._session.close() - async def fetch_random_dog(self) -> str | None: try: async with self._session.get(DOG_API_URL) as response: From f2f88b28c28f9df346a7f784b56e93850715ab87 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sun, 21 Jun 2026 14:17:19 +0200 Subject: [PATCH 3/5] Add alt text description to dog embed --- src/europython_discord/dog/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/europython_discord/dog/cog.py b/src/europython_discord/dog/cog.py index 9c6b0330..0810ca8b 100644 --- a/src/europython_discord/dog/cog.py +++ b/src/europython_discord/dog/cog.py @@ -32,6 +32,6 @@ async def dog_command(self, ctx: commands.Context) -> None: await ctx.send(message) return - embed = discord.Embed() + embed = discord.Embed(description="A random dog image") embed.set_image(url=image_url) await ctx.send(embed=embed) From aec5d29baf6fd717825b227fadfe8adbf9f0db63 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sun, 21 Jun 2026 15:48:53 +0200 Subject: [PATCH 4/5] Add channel restriction, cooldown, and embed description to dog command --- prod-config.toml | 2 +- src/europython_discord/dog/cog.py | 30 +++++++++++++++++++++++++--- src/europython_discord/dog/config.py | 2 ++ test-config.toml | 1 + tests/dog/test_cog.py | 15 ++++++++++++++ 5 files changed, 46 insertions(+), 4 deletions(-) diff --git a/prod-config.toml b/prod-config.toml index 63d75224..8a8c422f 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -67,7 +67,7 @@ main_notification_channel_name = "programme-notifications" required_role = "Organizers" [dog] -# this section is optional; defaults are used if omitted +cooldown_seconds = 10 error_messages = [ "The dogs are on strike today! Try again later. ๐Ÿพ๐Ÿชง", "A wild error appeared! The dog got away... ๐Ÿ•๐Ÿ’จ", diff --git a/src/europython_discord/dog/cog.py b/src/europython_discord/dog/cog.py index 0810ca8b..7eae8115 100644 --- a/src/europython_discord/dog/cog.py +++ b/src/europython_discord/dog/cog.py @@ -2,6 +2,8 @@ import logging import random +import time +from collections import OrderedDict import discord from discord.ext import commands @@ -11,6 +13,8 @@ _logger = logging.getLogger(__name__) +_MAX_COOLDOWN_TRACKING = 100 + class DogCog(commands.Cog): def __init__( @@ -22,16 +26,36 @@ def __init__( self.bot = bot self.config = config self._client = client or DogClient() + self._last_used: OrderedDict[int, float] = OrderedDict() _logger.info("Cog 'Dog' has been initialized") @commands.hybrid_command(name="dog", description="Get a random dog picture") async def dog_command(self, ctx: commands.Context) -> None: - image_url = await self._client.fetch_random_dog() - if image_url is None: + if ctx.channel.name != self.config.channel_name: + return + + if self._is_rate_limited(ctx.author.id): + return + + if (image_url := await self._client.fetch_random_dog()) is None: message = random.choice(self.config.error_messages) # noqa: S311 await ctx.send(message) return - embed = discord.Embed(description="A random dog image") + embed = discord.Embed() + embed.description = "A random dog image" embed.set_image(url=image_url) + embed.set_footer(text="Powered by dog.ceo") + + self._update_rate_limit_cache(ctx.author.id) await ctx.send(embed=embed) + + def _is_rate_limited(self, user_id: int) -> bool: + last = self._last_used.get(user_id) + return bool(last is not None and time.time() - last < self.config.cooldown_seconds) + + def _update_rate_limit_cache(self, user_id: int) -> None: + self._last_used[user_id] = time.time() + self._last_used.move_to_end(user_id) + if len(self._last_used) > _MAX_COOLDOWN_TRACKING: + self._last_used.popitem(last=False) diff --git a/src/europython_discord/dog/config.py b/src/europython_discord/dog/config.py index 0611979e..b0c5a686 100644 --- a/src/europython_discord/dog/config.py +++ b/src/europython_discord/dog/config.py @@ -2,4 +2,6 @@ class DogConfig(BaseModel): + channel_name: str = "pet-appreciation" + cooldown_seconds: int = 10 error_messages: list[str] = ["404: Dog not found. Have you checked under the couch? ๐Ÿ›‹๏ธ"] diff --git a/test-config.toml b/test-config.toml index 4f43ca67..4dbb17af 100644 --- a/test-config.toml +++ b/test-config.toml @@ -67,6 +67,7 @@ fast_mode = true required_role = "Organizers" [dog] +cooldown_seconds = 10 error_messages = [ "The dogs are on strike today! Try again later. ๐Ÿพ๐Ÿชง", "A wild error appeared! The dog got away... ๐Ÿ•๐Ÿ’จ", diff --git a/tests/dog/test_cog.py b/tests/dog/test_cog.py index b76adc15..83193507 100644 --- a/tests/dog/test_cog.py +++ b/tests/dog/test_cog.py @@ -38,6 +38,7 @@ def cog(bot: MagicMock, config: DogConfig, mock_client: DogClient) -> DogCog: @pytest.fixture def ctx() -> AsyncMock: mock = AsyncMock(spec=commands.Context) + mock.channel.name = "pet-appreciation" mock.send = AsyncMock() return mock @@ -59,3 +60,17 @@ async def test_dog_command_api_error(cog: DogCog, ctx: AsyncMock, mock_client: D text = ctx.send.call_args.args[0] assert text in cog.config.error_messages + + +@pytest.mark.parametrize( + "channel_name", + ["wrong-channel", "general", ""], +) +async def test_dog_command_wrong_channel(cog: DogCog, channel_name: str) -> None: + ctx = AsyncMock(spec=commands.Context) + ctx.channel.name = channel_name + ctx.send = AsyncMock() + + await cog.dog_command.callback(cog, ctx) + + ctx.send.assert_not_awaited() From f0d0381a066eb950b8630475753533a3ad8d8487 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Sun, 21 Jun 2026 21:40:40 +0200 Subject: [PATCH 5/5] Remove fixture from tests --- tests/dog/test_cog.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/tests/dog/test_cog.py b/tests/dog/test_cog.py index 83193507..2ffda507 100644 --- a/tests/dog/test_cog.py +++ b/tests/dog/test_cog.py @@ -19,14 +19,9 @@ def bot() -> MagicMock: @pytest.fixture -def dog_url() -> str: - return "https://images.dog.ceo/dog.jpg" - - -@pytest.fixture -def mock_client(dog_url: str) -> DogClient: +def mock_client() -> DogClient: client = MagicMock(spec=DogClient) - client.fetch_random_dog.return_value = dog_url + client.fetch_random_dog.return_value = "https://images.dog.ceo/dog.jpg" return client @@ -43,12 +38,12 @@ def ctx() -> AsyncMock: return mock -async def test_dog_command_success(cog: DogCog, ctx: AsyncMock, dog_url: str) -> None: +async def test_dog_command_success(cog: DogCog, ctx: AsyncMock) -> None: await cog.dog_command.callback(cog, ctx) ctx.send.assert_awaited_once() embed = ctx.send.call_args.kwargs["embed"] - assert embed.image.url == dog_url + assert embed.image.url == "https://images.dog.ceo/dog.jpg" async def test_dog_command_api_error(cog: DogCog, ctx: AsyncMock, mock_client: DogClient) -> None: