Skip to content

Commit 1555d6f

Browse files
committed
Refactor animal image generation
1 parent e614461 commit 1555d6f

19 files changed

Lines changed: 485 additions & 522 deletions

prod-config.toml

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -67,35 +67,3 @@ required_role = "Organisers"
6767
[animals]
6868
channel_name = "animal-appreciation"
6969
cooldown_seconds = 10
70-
71-
[animals.dog]
72-
error_messages = [
73-
"The dogs are on strike today! Try again later. 🐾🪧",
74-
"A wild error appeared! The dog got away... 🐕💨",
75-
"Dog API is fetching a stick. Throw it again! 🦴",
76-
"404: Dog not found. Have you checked under the couch? 🛋️",
77-
]
78-
79-
[animals.cat]
80-
error_messages = [
81-
"The cats are on strike today! Try again later. 🐾🪧",
82-
"A wild error appeared! The cat got away... 😿💨",
83-
"Cat API is fetching a mouse. Throw it again! 🦴",
84-
"404: Cat not found. Have you checked inside a cardboard box? 📦",
85-
]
86-
87-
[animals.duck]
88-
error_messages = [
89-
"The ducks are on strike today! Try again later. 🐾🪧",
90-
"A wild error appeared! The duck got away... 🦆💨",
91-
"Duck API is in a bit of a pond situation. Try again later! 🦆",
92-
"Quack! The duck API seems to be having an off day.",
93-
]
94-
95-
[animals.fox]
96-
error_messages = [
97-
"The foxes are on strike today! Try again later. 🐾🪧",
98-
"A wild error appeared! The fox got away... 🦊💨",
99-
"Fox API is fetching a chicken. Try again later! 🦴",
100-
"404: Fox not found. Have you checked under the bush? 🌳",
101-
]

src/europython_discord/animals/clients.py

Lines changed: 0 additions & 111 deletions
This file was deleted.

src/europython_discord/animals/cog.py

Lines changed: 51 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -2,103 +2,76 @@
22

33
import logging
44
import random
5-
import time
6-
from collections import OrderedDict
5+
from typing import Self
76

87
import discord
98
from discord.ext import commands
109

11-
from europython_discord.animals.clients import AnimalClient
12-
from europython_discord.animals.config import AnimalKind, AnimalsConfig
10+
from europython_discord.animals import providers
11+
from europython_discord.animals.config import AnimalsConfig
12+
from europython_discord.animals.providers import AnimalImageProvider
13+
from europython_discord.animals.rate_limiter import RateLimiter
1314

1415
_logger = logging.getLogger(__name__)
1516

16-
_MAX_COOLDOWN_TRACKING = 100
17-
1817

1918
class AnimalsCog(commands.Cog):
2019
def __init__(
2120
self,
2221
bot: commands.Bot,
23-
config: AnimalsConfig,
24-
client: AnimalClient | None = None,
22+
providers_by_animal: dict[str, list[AnimalImageProvider]],
23+
rate_limiter: RateLimiter,
24+
channel_name: str,
2525
) -> None:
26-
self.bot = bot
27-
self.config = config
28-
self._client = client or AnimalClient()
29-
self._last_usage_timestamp_by_user_id: OrderedDict[int, float] = OrderedDict()
26+
self._bot = bot
27+
self._channel_name = channel_name
28+
self._providers = providers_by_animal
29+
self._rate_limiter = rate_limiter
30+
31+
for animal in self._providers:
32+
self._bot.add_command(self._create_animal_command(animal))
33+
3034
_logger.info("Cog 'Animals' has been initialized")
3135

32-
async def handle_animal_command(self, ctx: commands.Context, animal: AnimalKind) -> None:
33-
if ctx.channel.name != self.config.channel_name:
36+
async def post_animal_picture(self, animal: str, ctx: commands.Context) -> None:
37+
if ctx.channel.name != self._channel_name:
3438
return
35-
36-
if self._is_rate_limited(ctx.author.id):
39+
if self._rate_limiter.is_rate_limited(ctx.author.id):
3740
return
38-
39-
result = await self._client.fetch_image(animal)
40-
if result is not None:
41-
embed = discord.Embed()
42-
embed.description = f"Behold! A friendly {animal} appeared from {result.source}"
43-
embed.set_image(url=result.url)
44-
self._update_rate_limit_cache(ctx.author.id)
45-
await ctx.send(embed=embed)
41+
if animal not in self._providers:
4642
return
4743

48-
await ctx.send(self._error_message_for(animal))
49-
50-
@commands.hybrid_command(name="dog", description="Get a random dog picture")
51-
async def dog_command(self, ctx: commands.Context) -> None:
52-
await self.handle_animal_command(ctx, AnimalKind.DOG)
53-
54-
@commands.hybrid_command(name="cat", description="Get a random cat picture")
55-
async def cat_command(self, ctx: commands.Context) -> None:
56-
await self.handle_animal_command(ctx, AnimalKind.CAT)
57-
58-
@commands.hybrid_command(name="duck", description="Get a random duck picture")
59-
async def duck_command(self, ctx: commands.Context) -> None:
60-
await self.handle_animal_command(ctx, AnimalKind.DUCK)
61-
62-
@commands.hybrid_command(name="fox", description="Get a random fox picture")
63-
async def fox_command(self, ctx: commands.Context) -> None:
64-
await self.handle_animal_command(ctx, AnimalKind.FOX)
65-
66-
async def cog_load(self) -> None:
67-
existing = {"dog", "cat", "duck", "fox"}
68-
for animal in AnimalKind:
69-
if animal in existing:
70-
continue
71-
cmd = _make_animality_command(animal, self)
72-
self.bot.add_command(cmd)
44+
provider = random.choice(self._providers[animal]) # noqa: S311 suspicious-non-cryptographic-random-usage
45+
try:
46+
image = await provider.generate_image()
47+
except Exception:
48+
_logger.exception("Error while generating %s image", animal)
49+
await ctx.send(f"Failed to fetch {animal} picture. Internal error, please report it.")
50+
return
7351

74-
def _error_message_for(self, animal: AnimalKind) -> str:
75-
if animal_config := self.config.config_by_kind.get(animal):
76-
return random.choice(animal_config.error_messages) # noqa: S311
52+
if image is None:
53+
_logger.error("Failed to fetch %s pictures", animal)
54+
await ctx.send(f"Failed to fetch {animal} picture.")
55+
return
7756

78-
if not self.config.animality_error_messages:
79-
return f"Couldn't find a {animal} picture right now."
80-
plural = f"{animal}s" if animal != "fish" else "fish"
81-
return random.choice(self.config.animality_error_messages).format( # noqa: S311
82-
animal=animal, plural=plural
57+
embed = discord.Embed()
58+
embed.description = f"Behold! A friendly {animal} appeared from {image.source}"
59+
embed.set_image(url=image.url)
60+
self._rate_limiter.register_usage(ctx.author.id)
61+
await ctx.send(embed=embed)
62+
63+
def _create_animal_command(self, animal: str) -> commands.HybridCommand:
64+
@commands.hybrid_command(name=animal, description=f"Get a random {animal} picture")
65+
async def callback(ctx: commands.Context) -> None:
66+
await self.post_animal_picture(animal, ctx)
67+
68+
return callback
69+
70+
@classmethod
71+
def from_config(cls, bot: commands.Bot, config: AnimalsConfig) -> Self:
72+
return cls(
73+
bot=bot,
74+
providers_by_animal=providers.get_all_providers(),
75+
rate_limiter=RateLimiter(config.cooldown_seconds),
76+
channel_name=config.channel_name,
8377
)
84-
85-
def _is_rate_limited(self, user_id: int) -> bool:
86-
last_usage_timestamp = self._last_usage_timestamp_by_user_id.get(user_id, 0)
87-
return last_usage_timestamp + self.config.cooldown_seconds > time.time()
88-
89-
def _update_rate_limit_cache(self, user_id: int) -> None:
90-
# update cache
91-
self._last_usage_timestamp_by_user_id[user_id] = time.time()
92-
93-
# trim cache
94-
self._last_usage_timestamp_by_user_id.move_to_end(user_id)
95-
if len(self._last_usage_timestamp_by_user_id) > _MAX_COOLDOWN_TRACKING:
96-
self._last_usage_timestamp_by_user_id.popitem(last=False)
97-
98-
99-
def _make_animality_command(name: AnimalKind, cog: AnimalsCog) -> commands.HybridCommand:
100-
@commands.hybrid_command(name=name, description=f"Get a random {name} picture")
101-
async def callback(ctx: commands.Context) -> None:
102-
await cog.handle_animal_command(ctx, name)
103-
104-
return callback
Lines changed: 0 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,8 @@
11
from __future__ import annotations
22

3-
from enum import StrEnum
4-
53
from pydantic import BaseModel
64

75

8-
class AnimalKind(StrEnum):
9-
CAT = "cat"
10-
DOG = "dog"
11-
BIRD = "bird"
12-
PANDA = "panda"
13-
REDPANDA = "redpanda"
14-
KOALA = "koala"
15-
FOX = "fox"
16-
WHALE = "whale"
17-
DOLPHIN = "dolphin"
18-
KANGAROO = "kangaroo"
19-
RABBIT = "rabbit"
20-
LION = "lion"
21-
BEAR = "bear"
22-
FROG = "frog"
23-
DUCK = "duck"
24-
PENGUIN = "penguin"
25-
AXOLOTL = "axolotl"
26-
CAPYBARA = "capybara"
27-
HEDGEHOG = "hedgehog"
28-
TURTLE = "turtle"
29-
NARWHAL = "narwhal"
30-
SQUIRREL = "squirrel"
31-
FISH = "fish"
32-
HORSE = "horse"
33-
34-
35-
class AnimalSpecificConfig(BaseModel):
36-
error_messages: list[str]
37-
38-
396
class AnimalsConfig(BaseModel):
407
channel_name: str
418
cooldown_seconds: int
42-
dog: AnimalSpecificConfig
43-
cat: AnimalSpecificConfig
44-
duck: AnimalSpecificConfig
45-
fox: AnimalSpecificConfig
46-
animality_error_messages: list[str] = [
47-
"The {plural} are on strike today! Try again later.",
48-
"A wild error appeared! The {animal} got away...",
49-
"The {animal} API is fetching a treat. Try again!",
50-
"404: {plural} not found. Have you checked the nearest zoo?",
51-
]
52-
53-
@property
54-
def config_by_kind(self) -> dict[AnimalKind, AnimalSpecificConfig]:
55-
return {
56-
AnimalKind.DOG: self.dog,
57-
AnimalKind.CAT: self.cat,
58-
AnimalKind.DUCK: self.duck,
59-
AnimalKind.FOX: self.fox,
60-
}

0 commit comments

Comments
 (0)