diff --git a/prod-config.toml b/prod-config.toml index fc331c77..e250c8f2 100644 --- a/prod-config.toml +++ b/prod-config.toml @@ -64,12 +64,38 @@ main_notification_channel_name = "programme-notifications" [guild_statistics] required_role = "Organizers" -[dog] +[animals] channel_name = "animal-appreciation" cooldown_seconds = 10 + +[animals.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? πŸ›‹οΈ", ] + +[animals.cat] +error_messages = [ + "The cats are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The cat got away... πŸ˜ΏπŸ’¨", + "Cat API is fetching a mouse. Throw it again! 🦴", + "404: Cat not found. Have you checked inside a cardboard box? πŸ“¦", +] + +[animals.duck] +error_messages = [ + "The ducks are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The duck got away... πŸ¦†πŸ’¨", + "Duck API is in a bit of a pond situation. Try again later! πŸ¦†", + "Quack! The duck API seems to be having an off day.", +] + +[animals.fox] +error_messages = [ + "The foxes are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The fox got away... πŸ¦ŠπŸ’¨", + "Fox API is fetching a chicken. Try again later! 🦴", + "404: Fox not found. Have you checked under the bush? 🌳", +] diff --git a/src/europython_discord/animals/__init__.py b/src/europython_discord/animals/__init__.py new file mode 100644 index 00000000..9d48db4f --- /dev/null +++ b/src/europython_discord/animals/__init__.py @@ -0,0 +1 @@ +from __future__ import annotations diff --git a/src/europython_discord/animals/clients.py b/src/europython_discord/animals/clients.py new file mode 100644 index 00000000..b5cdddce --- /dev/null +++ b/src/europython_discord/animals/clients.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +import time + +import aiohttp + +_logger = logging.getLogger(__name__) + +# APIs to fetch animals +DOG_API_URL = "https://dog.ceo/api/breeds/image/random" +CAT_API_URL = "https://cataas.com/cat/says/I%20love%20EuroPython" +DUCK_API_URL = "https://random-d.uk/api/randomimg" +FOX_API_URL = "https://randomfox.ca/floof/" + + +class AnimalClient: + def __init__(self) -> None: + self._session = aiohttp.ClientSession() + + async def fetch_image(self, animal: str) -> str | None: + """Fetch a random image for the given animal.""" + if animal == "dog": + return await self._fetch_dog() + if animal == "cat": + return await self._fetch_cat() + if animal == "duck": + return await self._fetch_duck() + if animal == "fox": + return await self._fetch_fox() + _logger.warning(f"Sadly we don't have {animal} pics yet :(") + return None + + async def _fetch_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.get("message") + + async def _fetch_fox(self) -> str | None: + try: + async with self._session.get(FOX_API_URL) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch fox image") + return None + + return data.get("image") + + async def _fetch_duck(self) -> str | None: + timestamp = int(time.time() * 1000) + return f"{DUCK_API_URL}?t={timestamp}" + + async def _fetch_cat(self) -> str | None: + params = { + "position": "center", + "font": "Impact", + "fontSize": "50", + "fontColor": "#fff", + "fontBackground": "none", + "json": "true", + } + try: + async with self._session.get(CAT_API_URL, params=params) as response: + response.raise_for_status() + data = await response.json() + except Exception: + _logger.exception("Failed to fetch cat image") + return None + + url = data.get("url") + if url: + if url.startswith("http"): + return url + return f"https://cataas.com{url}" + + return None diff --git a/src/europython_discord/animals/cog.py b/src/europython_discord/animals/cog.py new file mode 100644 index 00000000..5a9105f0 --- /dev/null +++ b/src/europython_discord/animals/cog.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +import logging +import random +import time +from collections import OrderedDict + +import discord +from discord.ext import commands + +from europython_discord.animals.clients import AnimalClient +from europython_discord.animals.config import AnimalsConfig + +_logger = logging.getLogger(__name__) + +_MAX_COOLDOWN_TRACKING = 100 + + +class AnimalsCog(commands.Cog): + def __init__( + self, + bot: commands.Bot, + config: AnimalsConfig, + client: AnimalClient | None = None, + ) -> None: + self.bot = bot + self.config = config + self._client = client or AnimalClient() + self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict() + _logger.info("Cog 'Animals' has been initialized") + + async def _handle_animal_command( + self, ctx: commands.Context, animal: str, source_url: str + ) -> None: + if ctx.channel.name != self.config.channel_name: + return + + if self._is_rate_limited(ctx.author.id): + return + + image_url = await self._client.fetch_image(animal) + if image_url is None: + # Get the error messages for this specific animal + animal_config = getattr(self.config, animal) + message = random.choice(animal_config.error_messages) # noqa: S311 + await ctx.send(message) + return + + embed = discord.Embed() + embed.description = f"Behold! A friendly {animal} appeared from {source_url}" + embed.set_image(url=image_url) + + self._update_rate_limit_cache(ctx.author.id) + await ctx.send(embed=embed) + + @commands.hybrid_command(name="dog", description="Get a random dog picture") + async def dog_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "dog", "https://dog.ceo") + + @commands.hybrid_command(name="cat", description="Get a random cat picture") + async def cat_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "cat", "https://cataas.com") + + @commands.hybrid_command(name="duck", description="Get a random duck picture") + async def duck_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "duck", "https://random-d.uk") + + @commands.hybrid_command(name="fox", description="Get a random fox picture") + async def fox_command(self, ctx: commands.Context) -> None: + await self._handle_animal_command(ctx, "fox", "https://randomfox.ca/floof/") + + def _is_rate_limited(self, user_id: int) -> bool: + last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) + return last_usage_timestamp + self.config.cooldown_seconds > time.time() + + def _update_rate_limit_cache(self, user_id: int) -> None: + # update cache + self._last_usage_timestamp_by_user_id[user_id] = time.time() + + # trim cache + self._last_usage_timestamp_by_user_id.move_to_end(user_id) + if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: + self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/animals/config.py b/src/europython_discord/animals/config.py new file mode 100644 index 00000000..35ff3db0 --- /dev/null +++ b/src/europython_discord/animals/config.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from pydantic import BaseModel + + +class AnimalSpecificConfig(BaseModel): + error_messages: list[str] + + +class AnimalsConfig(BaseModel): + channel_name: str + cooldown_seconds: int + dog: AnimalSpecificConfig + cat: AnimalSpecificConfig + duck: AnimalSpecificConfig + fox: AnimalSpecificConfig diff --git a/src/europython_discord/bot.py b/src/europython_discord/bot.py index b29e41ac..55ca2af8 100644 --- a/src/europython_discord/bot.py +++ b/src/europython_discord/bot.py @@ -13,10 +13,10 @@ from discord.ext import commands from pydantic import BaseModel +from europython_discord.animals.cog import AnimalsCog +from europython_discord.animals.config import AnimalsConfig 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 @@ -34,7 +34,7 @@ class Config(BaseModel): registration: RegistrationConfig program_notifications: ProgramNotificationsConfig guild_statistics: GuildStatisticsConfig - dog: DogConfig + animals: AnimalsConfig async def run_bot(config: Config, auth_token: str) -> None: @@ -47,7 +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(AnimalsCog(bot, config.animals)) 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/cog.py b/src/europython_discord/dog/cog.py deleted file mode 100644 index e52e63d0..00000000 --- a/src/europython_discord/dog/cog.py +++ /dev/null @@ -1,63 +0,0 @@ -from __future__ import annotations - -import logging -import random -import time -from collections import OrderedDict - -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__) - -_MAX_COOLDOWN_TRACKING = 100 - - -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() - self._last_usage_timestamp_by_user_id: 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: - 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() - embed.description = "A random dog image from https://dog.ceo" - embed.set_image(url=image_url) - - self._update_rate_limit_cache(ctx.author.id) - await ctx.send(embed=embed) - - def _is_rate_limited(self, user_id: int) -> bool: - last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0) - return last_usage_timestamp + self.config.cooldown_seconds > time.time() - - def _update_rate_limit_cache(self, user_id: int) -> None: - # update cache - self._last_usage_timestamp_by_user_id[user_id] = time.time() - - # trim cache - self._last_usage_timestamp_by_user_id.move_to_end(user_id) - if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING: - self._last_usage_timestamp_by_user_id.popitem(last=False) diff --git a/src/europython_discord/dog/config.py b/src/europython_discord/dog/config.py deleted file mode 100644 index 6f327ebf..00000000 --- a/src/europython_discord/dog/config.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class DogConfig(BaseModel): - channel_name: str - cooldown_seconds: int = 10 - 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 deleted file mode 100644 index 80c7dcbb..00000000 --- a/src/europython_discord/dog/dogclient.py +++ /dev/null @@ -1,25 +0,0 @@ -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 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 df33d084..48c7f16a 100644 --- a/test-config.toml +++ b/test-config.toml @@ -65,12 +65,38 @@ fast_mode = true [guild_statistics] required_role = "Organizers" -[dog] +[animals] channel_name = "animal-appreciation" cooldown_seconds = 10 + +[animals.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? πŸ›‹οΈ", ] + +[animals.cat] +error_messages = [ + "The cats are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The cat got away... πŸ˜ΏπŸ’¨", + "Cat API is fetching a mouse. Throw it again! 🦴", + "404: Cat not found. Have you checked inside a cardboard box? πŸ“¦", +] + +[animals.duck] +error_messages = [ + "The ducks are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The duck got away... πŸ¦†πŸ’¨", + "Duck API is in a bit of a pond situation. Try again later! πŸ¦†", + "Quack! The duck API seems to be having an off day.", +] + +[animals.fox] +error_messages = [ + "The foxes are on strike today! Try again later. 🐾πŸͺ§", + "A wild error appeared! The fox got away... πŸ¦ŠπŸ’¨", + "Fox API is fetching a chicken. Try again later! 🦴", + "404: Fox not found. Have you checked under the bush? 🌳", +] diff --git a/src/europython_discord/dog/__init__.py b/tests/animals/__init__.py similarity index 100% rename from src/europython_discord/dog/__init__.py rename to tests/animals/__init__.py diff --git a/tests/animals/test_clients.py b/tests/animals/test_clients.py new file mode 100644 index 00000000..ba8a8782 --- /dev/null +++ b/tests/animals/test_clients.py @@ -0,0 +1,92 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from aiohttp import ClientSession + +from europython_discord.animals.clients import AnimalClient + + +@pytest.fixture +async def client() -> AnimalClient: + c = AnimalClient() + yield c + await c._session.close() + + +async def test_fetch_dog(client: AnimalClient) -> None: + mock_response = AsyncMock() + mock_response.json = AsyncMock( + return_value={"message": "https://images.dog.ceo/dog.jpg", "status": "success"} + ) + mock_response.raise_for_status = MagicMock() + + mock_get = MagicMock() + mock_get.return_value.__aenter__.return_value = mock_response + + with patch.object(ClientSession, "get", mock_get): + url = await client.fetch_image("dog") + + assert url == "https://images.dog.ceo/dog.jpg" + mock_get.assert_called_once() + + +async def test_fetch_cat(client: AnimalClient) -> None: + mock_response = AsyncMock() + mock_response.json = AsyncMock(return_value={"url": "/cat/mockid"}) + mock_response.raise_for_status = MagicMock() + + mock_get = MagicMock() + mock_get.return_value.__aenter__.return_value = mock_response + + with patch.object(ClientSession, "get", mock_get): + url = await client.fetch_image("cat") + + assert url == "https://cataas.com/cat/mockid" + mock_get.assert_called_once() + + # Test absolute url + mock_response.json = AsyncMock(return_value={"url": "https://example.com/cat.jpg"}) + with patch.object(ClientSession, "get", mock_get): + url = await client.fetch_image("cat") + + assert url == "https://example.com/cat.jpg" + + +async def test_fetch_duck(client: AnimalClient) -> None: + # Duck API just returns a formatted URL without HTTP requests in the client + with patch("time.time", return_value=12345.0): + url = await client.fetch_image("duck") + + assert url == "https://random-d.uk/api/randomimg?t=12345000" + + +async def test_fetch_fox(client: AnimalClient) -> None: + mock_response = AsyncMock() + mock_response.json = AsyncMock( + return_value={"image": "https://randomfox.ca/images/123.jpg", "link": "..."} + ) + mock_response.raise_for_status = MagicMock() + + mock_get = MagicMock() + mock_get.return_value.__aenter__.return_value = mock_response + + with patch.object(ClientSession, "get", mock_get): + url = await client.fetch_image("fox") + + assert url == "https://randomfox.ca/images/123.jpg" + mock_get.assert_called_once() + + +async def test_fetch_unknown(client: AnimalClient) -> None: + url = await client.fetch_image("unknown_animal") + assert url is None + + +async def test_fetch_error(client: AnimalClient) -> None: + mock_get = MagicMock() + mock_get.return_value.__aenter__.side_effect = Exception("API Error") + + with patch.object(ClientSession, "get", mock_get): + url = await client.fetch_image("dog") + + assert url is None diff --git a/tests/animals/test_cog.py b/tests/animals/test_cog.py new file mode 100644 index 00000000..3cc6d849 --- /dev/null +++ b/tests/animals/test_cog.py @@ -0,0 +1,99 @@ +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest +from discord.ext import commands + +from europython_discord.animals.clients import AnimalClient +from europython_discord.animals.cog import AnimalsCog +from europython_discord.animals.config import AnimalsConfig, AnimalSpecificConfig + + +@pytest.fixture +def mock_client() -> AnimalClient: + client = MagicMock(spec=AnimalClient) + client.fetch_image = AsyncMock(return_value="https://example.com/animal.jpg") + return client + + +@pytest.fixture +def config() -> AnimalsConfig: + return AnimalsConfig( + channel_name="animal-appreciation", + cooldown_seconds=10, + dog=AnimalSpecificConfig(error_messages=["dog error"]), + cat=AnimalSpecificConfig(error_messages=["cat error"]), + duck=AnimalSpecificConfig(error_messages=["duck error"]), + fox=AnimalSpecificConfig(error_messages=["fox error"]), + ) + + +@pytest.fixture +def cog(mock_client: AnimalClient, config: AnimalsConfig) -> AnimalsCog: + bot = MagicMock(spec=commands.Bot) + return AnimalsCog(bot, config, client=mock_client) + + +@pytest.fixture +def ctx() -> AsyncMock: + mock = AsyncMock(spec=commands.Context) + mock.channel.name = "animal-appreciation" + mock.author = MagicMock() + mock.author.id = 12345 + mock.send = AsyncMock() + return mock + + +@pytest.mark.parametrize( + "command_name", ["dog_command", "cat_command", "duck_command", "fox_command"] +) +async def test_animal_commands_success(cog: AnimalsCog, ctx: AsyncMock, command_name: str) -> None: + command = getattr(cog, command_name) + await command.callback(cog, ctx) + + ctx.send.assert_awaited_once() + embed = ctx.send.call_args.kwargs["embed"] + assert embed.image.url == "https://example.com/animal.jpg" + assert "friendly" in embed.description + + +async def test_animal_command_api_error( + cog: AnimalsCog, ctx: AsyncMock, mock_client: AnimalClient +) -> None: + mock_client.fetch_image = AsyncMock(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.dog.error_messages + + +@pytest.mark.parametrize("channel_name", ["wrong-channel", "general", ""]) +async def test_animal_command_wrong_channel(cog: AnimalsCog, channel_name: str) -> None: + ctx = AsyncMock(spec=commands.Context) + ctx.channel.name = channel_name + ctx.author = MagicMock() + ctx.author.id = 12345 + ctx.send = AsyncMock() + + await cog.dog_command.callback(cog, ctx) + + ctx.send.assert_not_awaited() + + +async def test_animal_command_rate_limit(cog: AnimalsCog, ctx: AsyncMock) -> None: + # First call succeeds + await cog.dog_command.callback(cog, ctx) + assert ctx.send.call_count == 1 + + # Second call right after fails due to rate limit + await cog.dog_command.callback(cog, ctx) + assert ctx.send.call_count == 1 # Still 1, didn't increase + + # Time travel + cog._last_usage_timestamp_by_user_id[ctx.author.id] = time.time() - 20 + + # Third call succeeds + await cog.dog_command.callback(cog, ctx) + assert ctx.send.call_count == 2 diff --git a/tests/dog/__init__.py b/tests/dog/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/dog/test_cog.py b/tests/dog/test_cog.py deleted file mode 100644 index 599bc54d..00000000 --- a/tests/dog/test_cog.py +++ /dev/null @@ -1,60 +0,0 @@ -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 mock_client() -> DogClient: - client = MagicMock(spec=DogClient) - client.fetch_random_dog.return_value = "https://images.dog.ceo/dog.jpg" - return client - - -@pytest.fixture -def cog(mock_client: DogClient) -> DogCog: - bot = MagicMock(spec=commands.Bot) - config = DogConfig(channel_name="animal-appreciation") - return DogCog(bot, config, client=mock_client) - - -@pytest.fixture -def ctx() -> AsyncMock: - mock = AsyncMock(spec=commands.Context) - mock.channel.name = "animal-appreciation" - mock.send = AsyncMock() - return mock - - -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 == "https://images.dog.ceo/dog.jpg" - - -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 - - -@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()