Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion prod-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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? 🌳",
]
1 change: 1 addition & 0 deletions src/europython_discord/animals/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from __future__ import annotations
83 changes: 83 additions & 0 deletions src/europython_discord/animals/clients.py
Original file line number Diff line number Diff line change
@@ -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
83 changes: 83 additions & 0 deletions src/europython_discord/animals/cog.py
Original file line number Diff line number Diff line change
@@ -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)
16 changes: 16 additions & 0 deletions src/europython_discord/animals/config.py
Original file line number Diff line number Diff line change
@@ -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
8 changes: 4 additions & 4 deletions src/europython_discord/bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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))
Expand Down
63 changes: 0 additions & 63 deletions src/europython_discord/dog/cog.py

This file was deleted.

7 changes: 0 additions & 7 deletions src/europython_discord/dog/config.py

This file was deleted.

25 changes: 0 additions & 25 deletions src/europython_discord/dog/dogclient.py

This file was deleted.

28 changes: 27 additions & 1 deletion test-config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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? 🌳",
]
File renamed without changes.
Loading