-
Notifications
You must be signed in to change notification settings - Fork 18
Add animals plugin, with cats and dogs command #91
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,301 @@ | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import abc | ||||||||||||||||||||||||||||||
| from collections import defaultdict | ||||||||||||||||||||||||||||||
| from dataclasses import dataclass | ||||||||||||||||||||||||||||||
| import logging | ||||||||||||||||||||||||||||||
| from typing import Any, Literal, Optional | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from bot.acl import privileged | ||||||||||||||||||||||||||||||
| from bot.config import plugin_config_command | ||||||||||||||||||||||||||||||
| from discord.ext.commands import group | ||||||||||||||||||||||||||||||
| import discord | ||||||||||||||||||||||||||||||
| from bot.commands import Context, plugin_command | ||||||||||||||||||||||||||||||
| from discord.ext.commands import command | ||||||||||||||||||||||||||||||
| import aiohttp | ||||||||||||||||||||||||||||||
| import datetime as dt | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| from util.discord import UserError | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| CAT_API_ROOT = 'https://api.thecatapi.com/v1/images/search' | ||||||||||||||||||||||||||||||
| DOG_API_ROOT = 'https://api.thedogapi.com/v1/images/search' | ||||||||||||||||||||||||||||||
| TIMEOUT = 10 # seconds | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def with_suffix(obj: dict[str, Any], *path: str, suffix: str) -> Optional[str]: | ||||||||||||||||||||||||||||||
| val = obj | ||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| for p in path: | ||||||||||||||||||||||||||||||
| val = val[p] | ||||||||||||||||||||||||||||||
| except (KeyError, TypeError): | ||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||
| if val is not None: | ||||||||||||||||||||||||||||||
| return f'{val}{suffix}' | ||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class RateLimiter(abc.ABC): | ||||||||||||||||||||||||||||||
| @abc.abstractmethod | ||||||||||||||||||||||||||||||
| def is_allowed(self, ctx: Context) -> tuple[bool, str]: | ||||||||||||||||||||||||||||||
| pass | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class PerPersonRateLimiter(RateLimiter): | ||||||||||||||||||||||||||||||
| def __init__(self, calls: int, period: dt.timedelta): | ||||||||||||||||||||||||||||||
| self.calls = calls | ||||||||||||||||||||||||||||||
| self.period = period | ||||||||||||||||||||||||||||||
| self.users: dict[int, list[dt.datetime]] = defaultdict(list) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def __repr__(self) -> str: | ||||||||||||||||||||||||||||||
| return f'{self.__class__.__name__}<{self.calls=}, {self.period=}>' | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def __str__(self) -> str: | ||||||||||||||||||||||||||||||
| return f'{self.__class__.__name__}<{self.calls} calls per {self.period}>' | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def _is_allowed(self, user_id: int) -> tuple[bool, str]: | ||||||||||||||||||||||||||||||
| now = dt.datetime.now() | ||||||||||||||||||||||||||||||
| timestamps = self.users[user_id] | ||||||||||||||||||||||||||||||
| # Remove timestamps outside the period | ||||||||||||||||||||||||||||||
| while timestamps and now - timestamps[0] > self.period: | ||||||||||||||||||||||||||||||
| timestamps.pop(0) | ||||||||||||||||||||||||||||||
| if len(timestamps) < self.calls: | ||||||||||||||||||||||||||||||
| timestamps.append(now) | ||||||||||||||||||||||||||||||
| return True, '' | ||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||
| next_time = timestamps[0] + self.period | ||||||||||||||||||||||||||||||
| return False, f'Rate limit exceeded: {self.calls} calls per {self.period}. Please try again <t:{int(next_time.timestamp())}:R>.' | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def is_allowed(self, ctx: Context): | ||||||||||||||||||||||||||||||
| user_id = ctx.author.id | ||||||||||||||||||||||||||||||
| return self._is_allowed(user_id) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class GlobalRateLimiter(PerPersonRateLimiter): | ||||||||||||||||||||||||||||||
| def is_allowed(self, ctx: Context): | ||||||||||||||||||||||||||||||
| return self._is_allowed(0) # Use a single user ID for global rate limiting | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||
| class CatRequest: | ||||||||||||||||||||||||||||||
| limit: Optional[int] = None | ||||||||||||||||||||||||||||||
| page: Optional[int] = None | ||||||||||||||||||||||||||||||
| order: Literal['ASC', 'DESC', 'RAND', None] = None | ||||||||||||||||||||||||||||||
| has_breeds: Optional[bool] = None | ||||||||||||||||||||||||||||||
| breed_ids: Optional[list[str]] = None | ||||||||||||||||||||||||||||||
| #sub_id: Optional[str] | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def __post_init__(self): | ||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||
| assert self.limit is None or 1 <= self.limit <= 100 | ||||||||||||||||||||||||||||||
| assert self.page is None or 0 <= self.page | ||||||||||||||||||||||||||||||
| except AssertionError: | ||||||||||||||||||||||||||||||
| raise ValueError() | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def to_dict(self) -> dict[str, Any]: | ||||||||||||||||||||||||||||||
| result = {} | ||||||||||||||||||||||||||||||
| if self.limit is not None: | ||||||||||||||||||||||||||||||
| result['limit'] = self.limit | ||||||||||||||||||||||||||||||
| if self.page is not None: | ||||||||||||||||||||||||||||||
| result['page'] = self.page | ||||||||||||||||||||||||||||||
| if self.order is not None: | ||||||||||||||||||||||||||||||
| result['order'] = self.order | ||||||||||||||||||||||||||||||
| if self.has_breeds is not None: | ||||||||||||||||||||||||||||||
| result['has_breeds'] = int(self.has_breeds) | ||||||||||||||||||||||||||||||
| if self.breed_ids is not None: | ||||||||||||||||||||||||||||||
| result['breed_ids'] = ','.join(self.breed_ids) | ||||||||||||||||||||||||||||||
| return result | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||
| class DogRequest: | ||||||||||||||||||||||||||||||
| size: Literal['full', 'med', 'small', 'thumb'] = 'small' | ||||||||||||||||||||||||||||||
| mime_types: Optional[list[Literal['jpg', 'png', 'gif']]] = None | ||||||||||||||||||||||||||||||
| format: Literal['json', 'src'] = 'json' | ||||||||||||||||||||||||||||||
| order: Literal['ASC', 'DESC', 'RAND', None] = None | ||||||||||||||||||||||||||||||
| limit: Optional[int] = None | ||||||||||||||||||||||||||||||
| page: Optional[int] = None | ||||||||||||||||||||||||||||||
| has_breeds: Optional[bool] = None | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def to_dict(self) -> dict[str, Any]: | ||||||||||||||||||||||||||||||
| result = {} | ||||||||||||||||||||||||||||||
| result['size'] = self.size | ||||||||||||||||||||||||||||||
| if self.mime_types is not None: | ||||||||||||||||||||||||||||||
| result['mime_types'] = ','.join(self.mime_types) | ||||||||||||||||||||||||||||||
| result['format'] = self.format | ||||||||||||||||||||||||||||||
| result['order'] = self.order | ||||||||||||||||||||||||||||||
| result['limit'] = self.limit | ||||||||||||||||||||||||||||||
| result['page'] = self.page | ||||||||||||||||||||||||||||||
| if self.has_breeds is not None: | ||||||||||||||||||||||||||||||
| result['has_breeds'] = int(self.has_breeds) | ||||||||||||||||||||||||||||||
| return {k: v for k, v in result.items() if v is not None} | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| @dataclass | ||||||||||||||||||||||||||||||
| class AnimalResponse: | ||||||||||||||||||||||||||||||
| id: str | ||||||||||||||||||||||||||||||
| url: str | ||||||||||||||||||||||||||||||
| width: int | ||||||||||||||||||||||||||||||
| height: int | ||||||||||||||||||||||||||||||
| categories: Optional[list[Any]] = None | ||||||||||||||||||||||||||||||
| breeds: Optional[list[dict[str, Any]]] = None | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def get_weight(self) -> Optional[str]: | ||||||||||||||||||||||||||||||
| if self.breeds: | ||||||||||||||||||||||||||||||
| breed = self.breeds[0] | ||||||||||||||||||||||||||||||
| return ( | ||||||||||||||||||||||||||||||
| with_suffix(breed, 'weight', 'metric', suffix=' kg') or | ||||||||||||||||||||||||||||||
| with_suffix(breed, 'weight', 'imperial', suffix=' lbs') or | ||||||||||||||||||||||||||||||
| with_suffix(breed, 'weight', suffix='') | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def get_life_span(self) -> Optional[str]: | ||||||||||||||||||||||||||||||
| if self.breeds: | ||||||||||||||||||||||||||||||
| breed = self.breeds[0] | ||||||||||||||||||||||||||||||
| lifespan = breed.get('life_span', None) | ||||||||||||||||||||||||||||||
| if not lifespan: | ||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||
| if 'years' in lifespan: | ||||||||||||||||||||||||||||||
| return lifespan | ||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||
| return f'{lifespan} years' | ||||||||||||||||||||||||||||||
| return None | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def get_description(self) -> str: | ||||||||||||||||||||||||||||||
| if self.breeds: | ||||||||||||||||||||||||||||||
| breed = self.breeds[0] | ||||||||||||||||||||||||||||||
| out = {} | ||||||||||||||||||||||||||||||
| out['Name'] = breed.get('name', None) | ||||||||||||||||||||||||||||||
| out['Temperament'] = breed.get('temperament', None) | ||||||||||||||||||||||||||||||
| out['Origin'] = breed.get('origin', None) | ||||||||||||||||||||||||||||||
| out['Description'] = breed.get('description', None) | ||||||||||||||||||||||||||||||
| out['Weight'] = self.get_weight() | ||||||||||||||||||||||||||||||
| out['Life span'] = self.get_life_span() | ||||||||||||||||||||||||||||||
| if len(self.breeds) > 1: | ||||||||||||||||||||||||||||||
| other_breeds = [b.get('name', None) for b in self.breeds[1:]] | ||||||||||||||||||||||||||||||
| out['Other breeds'] = ', '.join(b for b in other_breeds if b) | ||||||||||||||||||||||||||||||
| description = '\n'.join(f'{key}: {value}' for key, value in out.items() if value) | ||||||||||||||||||||||||||||||
| return description | ||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||
| return 'No breed information available.' | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class AnimalApi: | ||||||||||||||||||||||||||||||
| def __init__(self, api_root: str, api_key: Optional[str], rate_limiters: list[RateLimiter]) -> None: | ||||||||||||||||||||||||||||||
| self.api_root = api_root | ||||||||||||||||||||||||||||||
| self.api_key = api_key | ||||||||||||||||||||||||||||||
| self.rate_limiters = rate_limiters | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def configure(self, api_key: str) -> None: | ||||||||||||||||||||||||||||||
| self.api_key = api_key | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| def get_configuration(self) -> str: | ||||||||||||||||||||||||||||||
| out = '' | ||||||||||||||||||||||||||||||
| out += f'API Root: {self.api_root}\n' | ||||||||||||||||||||||||||||||
| out += f'API Key: {self.api_key}\n' | ||||||||||||||||||||||||||||||
| for limiter in self.rate_limiters: | ||||||||||||||||||||||||||||||
| out += f'Rate Limiter: {limiter}\n' | ||||||||||||||||||||||||||||||
| return out | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async def fetch_random_animal(self, ctx: Context, params: dict[str, Any]) -> AnimalResponse: | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| logging.debug(f'Fetching random animal with params: {params}') | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| if not self.api_key: | ||||||||||||||||||||||||||||||
| raise UserError('Animal API is not configured.') | ||||||||||||||||||||||||||||||
| headers = {'x-api-key': self.api_key} | ||||||||||||||||||||||||||||||
| for limiter in self.rate_limiters: | ||||||||||||||||||||||||||||||
| allowed, message = limiter.is_allowed(ctx) | ||||||||||||||||||||||||||||||
| if not allowed: | ||||||||||||||||||||||||||||||
| raise UserError(message) | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| async with aiohttp.ClientSession() as session: | ||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||
| http: aiohttp.ClientSession = aiohttp.ClientSession() | |
| plugins.finalizer(http.close) |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This .configure call doesn't persist in any way, meaning if the bot is restarted it will lose the API key.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This will leak the API key, I would be more careful with it. E.g.
Lines 422 to 435 in 5cc74bd
| @config.command("submit_token") | |
| @privileged | |
| async def config_submit_token(ctx: Context, submit_token: Optional[str]) -> None: | |
| global conf | |
| async with sessionmaker() as session: | |
| c = await session.get(GlobalConfig, 0) | |
| assert c | |
| if submit_token is None: | |
| await ctx.send("None" if c.submit_token is None else format("{!i}", conf.submit_token)) | |
| else: | |
| c.submit_token = None if submit_token == "None" else submit_token | |
| await session.commit() | |
| conf = c | |
| await ctx.send("\u2705") |
.config phish submit_token
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes very little sense as a function. I understand you're trying to avoid manually handling
Nones, but this isn't the way to do it.