From 33e9f5e917ec105f88ae6ac5aa8622e39b8f9f87 Mon Sep 17 00:00:00 2001 From: Szymon Cader Date: Mon, 25 May 2026 23:33:13 +0200 Subject: [PATCH] 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