Skip to content

Commit 4f75327

Browse files
nekealNMertsch
andauthored
Add dog command with random picture feature (#347)
* Add dog command with random picture feature * Remove close method * Add alt text description to dog embed * Add channel restriction, cooldown, and embed description to dog command * Remove fixture from tests * Add pet-appreciation channel --------- Co-authored-by: Niklas Mertsch <49114330+NMertsch@users.noreply.github.com>
1 parent 28c9ad2 commit 4f75327

10 files changed

Lines changed: 202 additions & 2 deletions

File tree

prod-config.toml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ pretix_cache_file = "pretix_cache.json"
2727
# speakers
2828
"Presenter" = ["Participants", "Onsite Participants", "Speakers"]
2929
# onsite volunteers
30-
"On-site Volunteer" = ["Participants", "Onsite Participants", "Volunteers", "Onsite Volunteers"]
30+
"On-site Volunteer" = [
31+
"Participants",
32+
"Onsite Participants",
33+
"Volunteers",
34+
"Onsite Volunteers",
35+
]
3136
# beginners' day
3237
"Beginners’ Day Unconference / Humble Data Access" = ["Beginners Day"]
3338

@@ -58,3 +63,12 @@ main_notification_channel_name = "programme-notifications"
5863

5964
[guild_statistics]
6065
required_role = "Organizers"
66+
67+
[dog]
68+
cooldown_seconds = 10
69+
error_messages = [
70+
"The dogs are on strike today! Try again later. 🐾🪧",
71+
"A wild error appeared! The dog got away... 🐕💨",
72+
"Dog API is fetching a stick. Throw it again! 🦴",
73+
"404: Dog not found. Have you checked under the couch? 🛋️",
74+
]

scripts/configure-guild.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -542,6 +542,10 @@ def verify_permission_roles(self) -> Self:
542542
"Please bring found items to the registration desk."
543543
),
544544
),
545+
TextChannel(
546+
name="pet-appreciation",
547+
topic="A place to share pictures and stories about your pets!",
548+
),
545549
],
546550
permission_overwrites=[
547551
PermissionOverwrite(roles=[ROLE_EVERYONE], deny=["view_channel"]),

src/europython_discord/bot.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
from europython_discord.cogs.guild_statistics import GuildStatisticsCog, GuildStatisticsConfig
1717
from europython_discord.cogs.ping import PingCog
18+
from europython_discord.dog.cog import DogCog
19+
from europython_discord.dog.config import DogConfig
1820
from europython_discord.program_notifications.cog import ProgramNotificationsCog
1921
from europython_discord.program_notifications.config import ProgramNotificationsConfig
2022
from europython_discord.registration.cog import RegistrationCog
@@ -32,6 +34,7 @@ class Config(BaseModel):
3234
registration: RegistrationConfig
3335
program_notifications: ProgramNotificationsConfig
3436
guild_statistics: GuildStatisticsConfig
37+
dog: DogConfig = DogConfig()
3538

3639

3740
async def run_bot(config: Config, auth_token: str) -> None:
@@ -44,6 +47,7 @@ async def run_bot(config: Config, auth_token: str) -> None:
4447

4548
async with commands.Bot(intents=intents, command_prefix="$") as bot:
4649
await bot.add_cog(PingCog(bot))
50+
await bot.add_cog(DogCog(bot, config.dog))
4751
await bot.add_cog(RegistrationCog(bot, config.registration))
4852
await bot.add_cog(ProgramNotificationsCog(bot, config.program_notifications))
4953
await bot.add_cog(GuildStatisticsCog(bot, config.guild_statistics))

src/europython_discord/dog/__init__.py

Whitespace-only changes.

src/europython_discord/dog/cog.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import random
5+
import time
6+
from collections import OrderedDict
7+
8+
import discord
9+
from discord.ext import commands
10+
11+
from europython_discord.dog.config import DogConfig
12+
from europython_discord.dog.dogclient import DogClient
13+
14+
_logger = logging.getLogger(__name__)
15+
16+
_MAX_COOLDOWN_TRACKING = 100
17+
18+
19+
class DogCog(commands.Cog):
20+
def __init__(
21+
self,
22+
bot: commands.Bot,
23+
config: DogConfig,
24+
client: DogClient | None = None,
25+
) -> None:
26+
self.bot = bot
27+
self.config = config
28+
self._client = client or DogClient()
29+
self._last_used: OrderedDict[int, float] = OrderedDict()
30+
_logger.info("Cog 'Dog' has been initialized")
31+
32+
@commands.hybrid_command(name="dog", description="Get a random dog picture")
33+
async def dog_command(self, ctx: commands.Context) -> None:
34+
if ctx.channel.name != self.config.channel_name:
35+
return
36+
37+
if self._is_rate_limited(ctx.author.id):
38+
return
39+
40+
if (image_url := await self._client.fetch_random_dog()) is None:
41+
message = random.choice(self.config.error_messages) # noqa: S311
42+
await ctx.send(message)
43+
return
44+
45+
embed = discord.Embed()
46+
embed.description = "A random dog image"
47+
embed.set_image(url=image_url)
48+
embed.set_footer(text="Powered by dog.ceo")
49+
50+
self._update_rate_limit_cache(ctx.author.id)
51+
await ctx.send(embed=embed)
52+
53+
def _is_rate_limited(self, user_id: int) -> bool:
54+
last = self._last_used.get(user_id)
55+
return bool(last is not None and time.time() - last < self.config.cooldown_seconds)
56+
57+
def _update_rate_limit_cache(self, user_id: int) -> None:
58+
self._last_used[user_id] = time.time()
59+
self._last_used.move_to_end(user_id)
60+
if len(self._last_used) > _MAX_COOLDOWN_TRACKING:
61+
self._last_used.popitem(last=False)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel
2+
3+
4+
class DogConfig(BaseModel):
5+
channel_name: str = "pet-appreciation"
6+
cooldown_seconds: int = 10
7+
error_messages: list[str] = ["404: Dog not found. Have you checked under the couch? 🛋️"]
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
5+
import aiohttp
6+
7+
_logger = logging.getLogger(__name__)
8+
9+
DOG_API_URL = "https://dog.ceo/api/breeds/image/random"
10+
11+
12+
class DogClient:
13+
def __init__(self) -> None:
14+
self._session = aiohttp.ClientSession()
15+
16+
async def fetch_random_dog(self) -> str | None:
17+
try:
18+
async with self._session.get(DOG_API_URL) as response:
19+
response.raise_for_status()
20+
data = await response.json()
21+
except Exception:
22+
_logger.exception("Failed to fetch dog image")
23+
return None
24+
25+
return data["message"]

test-config.toml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ pretix_cache_file = "pretix_cache.json"
2727
# speakers
2828
"Presenter" = ["Participants", "Onsite Participants", "Speakers"]
2929
# onsite volunteers
30-
"On-site Volunteer" = ["Participants", "Onsite Participants", "Volunteers", "Onsite Volunteers"]
30+
"On-site Volunteer" = [
31+
"Participants",
32+
"Onsite Participants",
33+
"Volunteers",
34+
"Onsite Volunteers",
35+
]
3136
# beginners' day
3237
"Beginners’ Day Unconference / Humble Data Access" = ["Beginners Day"]
3338

@@ -59,3 +64,12 @@ fast_mode = true
5964

6065
[guild_statistics]
6166
required_role = "Organizers"
67+
68+
[dog]
69+
cooldown_seconds = 10
70+
error_messages = [
71+
"The dogs are on strike today! Try again later. 🐾🪧",
72+
"A wild error appeared! The dog got away... 🐕💨",
73+
"Dog API is fetching a stick. Throw it again! 🦴",
74+
"404: Dog not found. Have you checked under the couch? 🛋️",
75+
]

tests/dog/__init__.py

Whitespace-only changes.

tests/dog/test_cog.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from unittest.mock import AsyncMock, MagicMock
2+
3+
import pytest
4+
from discord.ext import commands
5+
6+
from europython_discord.dog.cog import DogCog
7+
from europython_discord.dog.config import DogConfig
8+
from europython_discord.dog.dogclient import DogClient
9+
10+
11+
@pytest.fixture
12+
def config() -> DogConfig:
13+
return DogConfig()
14+
15+
16+
@pytest.fixture
17+
def bot() -> MagicMock:
18+
return MagicMock(spec=commands.Bot)
19+
20+
21+
@pytest.fixture
22+
def mock_client() -> DogClient:
23+
client = MagicMock(spec=DogClient)
24+
client.fetch_random_dog.return_value = "https://images.dog.ceo/dog.jpg"
25+
return client
26+
27+
28+
@pytest.fixture
29+
def cog(bot: MagicMock, config: DogConfig, mock_client: DogClient) -> DogCog:
30+
return DogCog(bot, config, client=mock_client)
31+
32+
33+
@pytest.fixture
34+
def ctx() -> AsyncMock:
35+
mock = AsyncMock(spec=commands.Context)
36+
mock.channel.name = "pet-appreciation"
37+
mock.send = AsyncMock()
38+
return mock
39+
40+
41+
async def test_dog_command_success(cog: DogCog, ctx: AsyncMock) -> None:
42+
await cog.dog_command.callback(cog, ctx)
43+
44+
ctx.send.assert_awaited_once()
45+
embed = ctx.send.call_args.kwargs["embed"]
46+
assert embed.image.url == "https://images.dog.ceo/dog.jpg"
47+
48+
49+
async def test_dog_command_api_error(cog: DogCog, ctx: AsyncMock, mock_client: DogClient) -> None:
50+
mock_client.fetch_random_dog.return_value = None
51+
52+
await cog.dog_command.callback(cog, ctx)
53+
54+
ctx.send.assert_awaited_once()
55+
text = ctx.send.call_args.args[0]
56+
57+
assert text in cog.config.error_messages
58+
59+
60+
@pytest.mark.parametrize(
61+
"channel_name",
62+
["wrong-channel", "general", ""],
63+
)
64+
async def test_dog_command_wrong_channel(cog: DogCog, channel_name: str) -> None:
65+
ctx = AsyncMock(spec=commands.Context)
66+
ctx.channel.name = channel_name
67+
ctx.send = AsyncMock()
68+
69+
await cog.dog_command.callback(cog, ctx)
70+
71+
ctx.send.assert_not_awaited()

0 commit comments

Comments
 (0)