diff --git a/prod-config.toml b/prod-config.toml index b138e6b..63d7522 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 be7e172..056d5c2 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 0000000..e69de29 diff --git a/src/europython_discord/dog/cog.py b/src/europython_discord/dog/cog.py new file mode 100644 index 0000000..9c6b033 --- /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 0000000..0611979 --- /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 0000000..85e5727 --- /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 b84bd69..4f43ca6 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 0000000..e69de29 diff --git a/tests/dog/test_cog.py b/tests/dog/test_cog.py new file mode 100644 index 0000000..b76adc1 --- /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