Skip to content

Commit e5de246

Browse files
committed
Revert "Refactor animal image generation commands"
This reverts commit 866d496.
1 parent 866d496 commit e5de246

19 files changed

Lines changed: 522 additions & 459 deletions

prod-config.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,35 @@ 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+
]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from __future__ import annotations
2+
3+
import logging
4+
import time
5+
from dataclasses import dataclass
6+
7+
import aiohttp
8+
9+
_logger = logging.getLogger(__name__)
10+
11+
12+
@dataclass
13+
class ImageResult:
14+
url: str
15+
source: str
16+
17+
18+
# APIs to fetch animals
19+
DOG_API_URL = "https://dog.ceo/api/breeds/image/random"
20+
DOG_SOURCE = "https://dog.ceo"
21+
CAT_API_URL = "https://cataas.com/cat/says/I%20love%20EuroPython"
22+
CAT_SOURCE = "https://cataas.com"
23+
DUCK_API_URL = "https://random-d.uk/api/randomimg"
24+
DUCK_SOURCE = "https://random-d.uk"
25+
FOX_API_URL = "https://randomfox.ca/floof/"
26+
FOX_SOURCE = "https://randomfox.ca/floof/"
27+
ANIMALITY_API_URL = "https://api.animality.xyz/img"
28+
ANIMALITY_SOURCE = "https://animality.xyz"
29+
30+
31+
class AnimalClient:
32+
def __init__(self) -> None:
33+
self._session = aiohttp.ClientSession()
34+
35+
async def fetch_image(self, animal: str) -> ImageResult | None:
36+
"""Fetch a random image for the given animal."""
37+
if animal == "dog":
38+
return await self._fetch_dog() or await self._fetch_animality(animal)
39+
if animal == "cat":
40+
return await self._fetch_cat() or await self._fetch_animality(animal)
41+
if animal == "duck":
42+
return await self._fetch_duck() or await self._fetch_animality(animal)
43+
if animal == "fox":
44+
return await self._fetch_fox() or await self._fetch_animality(animal)
45+
46+
return await self._fetch_animality(animal)
47+
48+
async def _fetch_dog(self) -> ImageResult | None:
49+
try:
50+
async with self._session.get(DOG_API_URL) as response:
51+
response.raise_for_status()
52+
data = await response.json()
53+
except Exception:
54+
_logger.exception("Failed to fetch dog image")
55+
return None
56+
57+
url = data.get("message")
58+
return ImageResult(url, DOG_SOURCE) if url else None
59+
60+
async def _fetch_fox(self) -> ImageResult | None:
61+
try:
62+
async with self._session.get(FOX_API_URL) as response:
63+
response.raise_for_status()
64+
data = await response.json()
65+
except Exception:
66+
_logger.exception("Failed to fetch fox image")
67+
return None
68+
69+
url = data.get("image")
70+
return ImageResult(url, FOX_SOURCE) if url else None
71+
72+
async def _fetch_duck(self) -> ImageResult | None:
73+
timestamp = int(time.time() * 1000)
74+
return ImageResult(f"{DUCK_API_URL}?t={timestamp}", DUCK_SOURCE)
75+
76+
async def _fetch_cat(self) -> ImageResult | None:
77+
params = {
78+
"position": "center",
79+
"font": "Impact",
80+
"fontSize": "50",
81+
"fontColor": "#fff",
82+
"fontBackground": "none",
83+
"json": "true",
84+
}
85+
try:
86+
async with self._session.get(CAT_API_URL, params=params) as response:
87+
response.raise_for_status()
88+
data = await response.json()
89+
except Exception:
90+
_logger.exception("Failed to fetch cat image")
91+
return None
92+
93+
url = data.get("url")
94+
if url:
95+
if url.startswith("http"):
96+
return ImageResult(url, CAT_SOURCE)
97+
return ImageResult(f"https://cataas.com{url}", CAT_SOURCE)
98+
99+
return None
100+
101+
async def _fetch_animality(self, animal: str) -> ImageResult | None:
102+
try:
103+
async with self._session.get(f"{ANIMALITY_API_URL}/{animal}") as response:
104+
response.raise_for_status()
105+
data = await response.json()
106+
except Exception:
107+
_logger.exception("Failed to fetch animality image for %s", animal)
108+
return None
109+
110+
url = data.get("image")
111+
return ImageResult(url, ANIMALITY_SOURCE) if url else None

src/europython_discord/animals/cog.py

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

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

78
import discord
89
from discord.ext import commands
910

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
11+
from europython_discord.animals.clients import AnimalClient
12+
from europython_discord.animals.config import AnimalKind, AnimalsConfig
1413

1514
_logger = logging.getLogger(__name__)
1615

16+
_MAX_COOLDOWN_TRACKING = 100
17+
1718

1819
class AnimalsCog(commands.Cog):
1920
def __init__(
2021
self,
2122
bot: commands.Bot,
22-
providers_by_animal: dict[str, list[AnimalImageProvider]],
23-
rate_limiter: RateLimiter,
24-
channel_name: str,
23+
config: AnimalsConfig,
24+
client: AnimalClient | None = None,
2525
) -> None:
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-
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()
3430
_logger.info("Cog 'Animals' has been initialized")
3531

36-
async def post_animal_picture(self, animal: str, ctx: commands.Context) -> None:
37-
if ctx.channel.name != self._channel_name:
38-
return
39-
if self._rate_limiter.is_rate_limited(ctx.author.id):
40-
return
41-
if animal not in self._providers:
32+
async def handle_animal_command(self, ctx: commands.Context, animal: AnimalKind) -> None:
33+
if ctx.channel.name != self.config.channel_name:
4234
return
4335

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.")
36+
if self._is_rate_limited(ctx.author.id):
5037
return
5138

52-
if image is None:
53-
_logger.error("Failed to fetch %s pictures", animal)
54-
await ctx.send(f"Failed to fetch {animal} picture.")
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)
5546
return
5647

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,
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)
73+
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
77+
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
7783
)
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: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,60 @@
11
from __future__ import annotations
22

3+
from enum import StrEnum
4+
35
from pydantic import BaseModel
46

57

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+
639
class AnimalsConfig(BaseModel):
740
channel_name: str
841
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)