diff options
| -rw-r--r-- | bot/constants.py | 1 | ||||
| -rw-r--r-- | bot/exts/evergreen/game.py | 37 | ||||
| -rw-r--r-- | bot/exts/evergreen/space.py | 240 | 
3 files changed, 274 insertions, 4 deletions
| diff --git a/bot/constants.py b/bot/constants.py index 638448ea..ca9bb94a 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -181,6 +181,7 @@ class Tokens(NamedTuple):      omdb = environ.get("OMDB_API_KEY")      youtube = environ.get("YOUTUBE_API_KEY")      tmdb = environ.get("TMDB_API_KEY") +    nasa = environ.get("NASA_API_KEY")      igdb = environ.get("IGDB_API_KEY")      github = environ.get("GITHUB_TOKEN") diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index d43b1ad6..3c8b2725 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -1,6 +1,7 @@  import difflib  import logging  import random +import re  from datetime import datetime as dt  from enum import IntEnum  from typing import Any, Dict, List, Optional, Tuple @@ -25,6 +26,8 @@ HEADERS = {  logger = logging.getLogger(__name__) +REGEX_NON_ALPHABET = re.compile(r"[^a-z0-9]", re.IGNORECASE) +  # ---------  # TEMPLATES  # --------- @@ -135,7 +138,7 @@ class Games(Cog):          self.refresh_genres_task.start() -    @tasks.loop(hours=1.0) +    @tasks.loop(hours=24.0)      async def refresh_genres_task(self) -> None:          """Refresh genres in every hour."""          try: @@ -194,9 +197,25 @@ class Games(Cog):          try:              games = await self.get_games_list(amount, self.genres[genre], offset=random.randint(0, 150))          except KeyError: -            possibilities = "`, `".join(difflib.get_close_matches(genre, self.genres)) -            await ctx.send(f"Invalid genre `{genre}`. {f'Maybe you meant `{possibilities}`?' if possibilities else ''}") -            return +            possibilities = await self.get_best_results(genre) +            # If there is more than 1 possibilities, show these. +            # If there is only 1 possibility, use it as genre. +            # Otherwise send message about invalid genre. +            if len(possibilities) > 1: +                display_possibilities = "`, `".join(p[1] for p in possibilities) +                await ctx.send( +                    f"Invalid genre `{genre}`. " +                    f"{f'Maybe you meant `{display_possibilities}`?' if display_possibilities else ''}" +                ) +                return +            elif len(possibilities) == 1: +                games = await self.get_games_list( +                    amount, self.genres[possibilities[0][1]], offset=random.randint(0, 150) +                ) +                genre = possibilities[0][1] +            else: +                await ctx.send(f"Invalid genre `{genre}`.") +                return          # Create pages and paginate          pages = [await self.create_page(game) for game in games] @@ -385,6 +404,16 @@ class Games(Cog):          return page, url +    async def get_best_results(self, query: str) -> List[Tuple[float, str]]: +        """Get best match result of genre when original genre is invalid.""" +        results = [] +        for genre in self.genres: +            ratios = [difflib.SequenceMatcher(None, query, genre).ratio()] +            for word in REGEX_NON_ALPHABET.split(genre): +                ratios.append(difflib.SequenceMatcher(None, query, word).ratio()) +            results.append((round(max(ratios), 2), genre)) +        return sorted((item for item in results if item[0] >= 0.60), reverse=True)[:4] +  def setup(bot: SeasonalBot) -> None:      """Add/Load Games cog.""" diff --git a/bot/exts/evergreen/space.py b/bot/exts/evergreen/space.py new file mode 100644 index 00000000..89b31e87 --- /dev/null +++ b/bot/exts/evergreen/space.py @@ -0,0 +1,240 @@ +import logging +import random +from datetime import datetime +from typing import Any, Dict, Optional, Union +from urllib.parse import urlencode + +from discord import Embed +from discord.ext import tasks +from discord.ext.commands import BadArgument, Cog, Context, Converter, group + +from bot.bot import SeasonalBot +from bot.constants import Tokens + +logger = logging.getLogger(__name__) + +NASA_BASE_URL = "https://api.nasa.gov" +NASA_IMAGES_BASE_URL = "https://images-api.nasa.gov" +NASA_EPIC_BASE_URL = "https://epic.gsfc.nasa.gov" + + +class DateConverter(Converter): +    """Parse SOL or earth date (in format YYYY-MM-DD) into `int` or `datetime`. When invalid input, raise error.""" + +    async def convert(self, ctx: Context, argument: str) -> Union[int, datetime]: +        """Parse date (SOL or earth) into `datetime` or `int`. When invalid value, raise error.""" +        if argument.isdigit(): +            return int(argument) +        try: +            date = datetime.strptime(argument, "%Y-%m-%d") +        except ValueError: +            raise BadArgument(f"Can't convert `{argument}` to `datetime` in format `YYYY-MM-DD` or `int` in SOL.") +        return date + + +class Space(Cog): +    """Space Cog contains commands, that show images, facts or other information about space.""" + +    def __init__(self, bot: SeasonalBot): +        self.bot = bot +        self.http_session = bot.http_session + +        self.rovers = {} +        self.get_rovers.start() + +    def cog_unload(self) -> None: +        """Cancel `get_rovers` task when Cog will unload.""" +        self.get_rovers.cancel() + +    @tasks.loop(hours=24) +    async def get_rovers(self) -> None: +        """Get listing of rovers from NASA API and info about their start and end dates.""" +        data = await self.fetch_from_nasa("mars-photos/api/v1/rovers") + +        for rover in data["rovers"]: +            self.rovers[rover["name"].lower()] = { +                "min_date": rover["landing_date"], +                "max_date": rover["max_date"], +                "max_sol": rover["max_sol"] +            } + +    @group(name="space", invoke_without_command=True) +    async def space(self, ctx: Context) -> None: +        """Head command that contains commands about space.""" +        await ctx.send_help("space") + +    @space.command(name="apod") +    async def apod(self, ctx: Context, date: Optional[str] = None) -> None: +        """ +        Get Astronomy Picture of Day from NASA API. Date is optional parameter, what formatting is YYYY-MM-DD. + +        If date is not specified, this will get today APOD. +        """ +        params = {} +        # Parse date to params, when provided. Show error message when invalid formatting +        if date: +            try: +                params["date"] = datetime.strptime(date, "%Y-%m-%d").date().isoformat() +            except ValueError: +                await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") +                return + +        result = await self.fetch_from_nasa("planetary/apod", params) + +        await ctx.send( +            embed=self.create_nasa_embed( +                f"Astronomy Picture of the Day - {result['date']}", +                result["explanation"], +                result["url"] +            ) +        ) + +    @space.command(name="nasa") +    async def nasa(self, ctx: Context, *, search_term: Optional[str] = None) -> None: +        """Get random NASA information/facts + image. Support `search_term` parameter for more specific search.""" +        params = { +            "media_type": "image" +        } +        if search_term: +            params["q"] = search_term + +        # Don't use API key, no need for this. +        data = await self.fetch_from_nasa("search", params, NASA_IMAGES_BASE_URL, use_api_key=False) +        if len(data["collection"]["items"]) == 0: +            await ctx.send(f"Can't find any items with search term `{search_term}`.") +            return + +        item = random.choice(data["collection"]["items"]) + +        await ctx.send( +            embed=self.create_nasa_embed( +                item["data"][0]["title"], +                item["data"][0]["description"], +                item["links"][0]["href"] +            ) +        ) + +    @space.command(name="epic") +    async def epic(self, ctx: Context, date: Optional[str] = None) -> None: +        """Get one of latest random image of earth from NASA EPIC API. Support date parameter, format is YYYY-MM-DD.""" +        if date: +            try: +                show_date = datetime.strptime(date, "%Y-%m-%d").date().isoformat() +            except ValueError: +                await ctx.send(f"Invalid date {date}. Please make sure your date is in format YYYY-MM-DD.") +                return +        else: +            show_date = None + +        # Don't use API key, no need for this. +        data = await self.fetch_from_nasa( +            f"api/natural{f'/date/{show_date}' if show_date else ''}", +            base=NASA_EPIC_BASE_URL, +            use_api_key=False +        ) +        if len(data) < 1: +            await ctx.send("Can't find any images in this date.") +            return + +        item = random.choice(data) + +        year, month, day = item["date"].split(" ")[0].split("-") +        image_url = f"{NASA_EPIC_BASE_URL}/archive/natural/{year}/{month}/{day}/jpg/{item['image']}.jpg" + +        await ctx.send( +            embed=self.create_nasa_embed( +                "Earth Image", item["caption"], image_url, f" \u2022 Identifier: {item['identifier']}" +            ) +        ) + +    @space.group(name="mars", invoke_without_command=True) +    async def mars( +        self, +        ctx: Context, +        date: Optional[DateConverter] = None, +        rover: Optional[str] = "curiosity" +    ) -> None: +        """ +        Get random Mars image by date. Support both SOL (martian solar day) and earth date and rovers. + +        Earth date formatting is YYYY-MM-DD. Use `.space mars dates` to get all currently available rovers. +        """ +        rover = rover.lower() +        if rover not in self.rovers: +            await ctx.send( +                ( +                    f"Invalid rover `{rover}`.\n" +                    f"**Rovers:** `{'`, `'.join(f'{r.capitalize()}' for r in self.rovers)}`" +                ) +            ) +            return + +        # When date not provided, get random SOL date between 0 and rover's max. +        if date is None: +            date = random.randint(0, self.rovers[rover]["max_sol"]) + +        params = {} +        if isinstance(date, int): +            params["sol"] = date +        else: +            params["earth_date"] = date.date().isoformat() + +        result = await self.fetch_from_nasa(f"mars-photos/api/v1/rovers/{rover}/photos", params) +        if len(result["photos"]) < 1: +            err_msg = ( +                f"We can't find result in date " +                f"{date.date().isoformat() if isinstance(date, datetime) else f'{date} SOL'}.\n" +                f"**Note:** Dates must match with rover's working dates. Please use `{ctx.prefix}space mars dates` to " +                "see working dates for each rover." +            ) +            await ctx.send(err_msg) +            return + +        item = random.choice(result["photos"]) +        await ctx.send( +            embed=self.create_nasa_embed( +                f"{item['rover']['name']}'s {item['camera']['full_name']} Mars Image", "", item["img_src"], +            ) +        ) + +    @mars.command(name="dates", aliases=["d", "date", "rover", "rovers", "r"]) +    async def dates(self, ctx: Context) -> None: +        """Get current available rovers photo date ranges.""" +        await ctx.send("\n".join( +            f"**{r.capitalize()}:** {i['min_date']} **-** {i['max_date']}" for r, i in self.rovers.items() +        )) + +    async def fetch_from_nasa( +        self, +        endpoint: str, +        additional_params: Optional[Dict[str, Any]] = None, +        base: Optional[str] = NASA_BASE_URL, +        use_api_key: bool = True +    ) -> Dict[str, Any]: +        """Fetch information from NASA API, return result.""" +        params = {} +        if use_api_key: +            params["api_key"] = Tokens.nasa + +        # Add additional parameters to request parameters only when they provided by user +        if additional_params is not None: +            params.update(additional_params) + +        async with self.http_session.get(url=f"{base}/{endpoint}?{urlencode(params)}") as resp: +            return await resp.json() + +    def create_nasa_embed(self, title: str, description: str, image: str, footer: Optional[str] = "") -> Embed: +        """Generate NASA commands embeds. Required: title, description and image URL, footer (addition) is optional.""" +        return Embed( +            title=title, +            description=description +        ).set_image(url=image).set_footer(text="Powered by NASA API" + footer) + + +def setup(bot: SeasonalBot) -> None: +    """Load Space Cog.""" +    if not Tokens.nasa: +        logger.warning("Can't find NASA API key. Not loading Space Cog.") +        return + +    bot.add_cog(Space(bot)) | 
