diff options
Diffstat (limited to 'bot/seasons')
| -rw-r--r-- | bot/seasons/christmas/adventofcode.py | 2 | ||||
| -rw-r--r-- | bot/seasons/easter/__init__.py | 2 | ||||
| -rw-r--r-- | bot/seasons/easter/egg_facts.py | 2 | ||||
| -rw-r--r-- | bot/seasons/evergreen/bookmark.py | 130 | ||||
| -rw-r--r-- | bot/seasons/evergreen/game.py | 395 | ||||
| -rw-r--r-- | bot/seasons/evergreen/issues.py | 5 | ||||
| -rw-r--r-- | bot/seasons/evergreen/movie.py | 198 | ||||
| -rw-r--r-- | bot/seasons/evergreen/reddit.py | 130 | ||||
| -rw-r--r-- | bot/seasons/evergreen/snakes/snakes_cog.py | 4 | ||||
| -rw-r--r-- | bot/seasons/halloween/candy_collection.py | 8 | ||||
| -rw-r--r-- | bot/seasons/halloween/hacktoberstats.py | 14 | ||||
| -rw-r--r-- | bot/seasons/halloween/halloween_facts.py | 2 | ||||
| -rw-r--r-- | bot/seasons/pride/__init__.py | 2 | ||||
| -rw-r--r-- | bot/seasons/pride/pride_facts.py | 2 | ||||
| -rw-r--r-- | bot/seasons/season.py | 15 | ||||
| -rw-r--r-- | bot/seasons/valentines/be_my_valentine.py | 8 | 
16 files changed, 826 insertions, 93 deletions
diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index f2ec83df..8caf43bd 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -364,7 +364,7 @@ class AdventOfCode(commands.Cog):              aoc_embed.set_footer(text="Last Updated")          await ctx.send( -            content=f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}",  # noqa +            f"Here's the current global Top {number_of_people_to_display}! {Emojis.christmas_tree*3}\n\n{table}",              embed=aoc_embed,          ) diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py index 1d77b6a6..dd60bf5c 100644 --- a/bot/seasons/easter/__init__.py +++ b/bot/seasons/easter/__init__.py @@ -16,7 +16,7 @@ class Easter(SeasonBase):       • You may see stuff like an Easter themed esoteric challenge, a celebration of Earth Day, or      Easter-related micro-events for you to join. Stay tuned! -    If you'd like to contribute, head on over to <#542272993192050698> and we will help you get +    If you'd like to contribute, head on over to <#635950537262759947> and we will help you get      started. It doesn't matter if you're new to open source or Python, if you'd like to help, we      will find you a task and teach you what you need to know.      """ diff --git a/bot/seasons/easter/egg_facts.py b/bot/seasons/easter/egg_facts.py index 9e6fb1cb..e66e25a3 100644 --- a/bot/seasons/easter/egg_facts.py +++ b/bot/seasons/easter/egg_facts.py @@ -34,7 +34,7 @@ class EasterFacts(commands.Cog):      async def send_egg_fact_daily(self) -> None:          """A background task that sends an easter egg fact in the event channel everyday.""" -        channel = self.bot.get_channel(Channels.seasonalbot_chat) +        channel = self.bot.get_channel(Channels.seasonalbot_commands)          while True:              embed = self.make_embed()              await channel.send(embed=embed) diff --git a/bot/seasons/evergreen/bookmark.py b/bot/seasons/evergreen/bookmark.py index 7bdd362c..bd7d5c11 100644 --- a/bot/seasons/evergreen/bookmark.py +++ b/bot/seasons/evergreen/bookmark.py @@ -1,65 +1,65 @@ -import logging
 -import random
 -
 -import discord
 -from discord.ext import commands
 -
 -from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url
 -
 -log = logging.getLogger(__name__)
 -
 -
 -class Bookmark(commands.Cog):
 -    """Creates personal bookmarks by relaying a message link to the user's DMs."""
 -
 -    def __init__(self, bot: commands.Bot):
 -        self.bot = bot
 -
 -    @commands.command(name="bookmark", aliases=("bm", "pin"))
 -    async def bookmark(
 -        self,
 -        ctx: commands.Context,
 -        target_message: discord.Message,
 -        *,
 -        title: str = "Bookmark"
 -    ) -> None:
 -        """Send the author a link to `target_message` via DMs."""
 -        # Prevent users from bookmarking a message in a channel they don't have access to
 -        permissions = ctx.author.permissions_in(target_message.channel)
 -        if not permissions.read_messages:
 -            log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions")
 -            embed = discord.Embed(
 -                title=random.choice(ERROR_REPLIES),
 -                color=Colours.soft_red,
 -                description="You don't have permission to view this channel."
 -            )
 -            await ctx.send(embed=embed)
 -            return
 -
 -        embed = discord.Embed(
 -            title=title,
 -            colour=Colours.soft_green,
 -            description=target_message.content
 -        )
 -        embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})")
 -        embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url)
 -        embed.set_thumbnail(url=bookmark_icon_url)
 -
 -        try:
 -            await ctx.author.send(embed=embed)
 -        except discord.Forbidden:
 -            error_embed = discord.Embed(
 -                title=random.choice(ERROR_REPLIES),
 -                description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark",
 -                colour=Colours.soft_red
 -            )
 -            await ctx.send(embed=error_embed)
 -        else:
 -            log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'")
 -            await ctx.message.add_reaction(Emojis.envelope)
 -
 -
 -def setup(bot: commands.Bot) -> None:
 -    """Load the Bookmark cog."""
 -    bot.add_cog(Bookmark(bot))
 -    log.info("Bookmark cog loaded")
 +import logging +import random + +import discord +from discord.ext import commands + +from bot.constants import Colours, ERROR_REPLIES, Emojis, bookmark_icon_url + +log = logging.getLogger(__name__) + + +class Bookmark(commands.Cog): +    """Creates personal bookmarks by relaying a message link to the user's DMs.""" + +    def __init__(self, bot: commands.Bot): +        self.bot = bot + +    @commands.command(name="bookmark", aliases=("bm", "pin")) +    async def bookmark( +        self, +        ctx: commands.Context, +        target_message: discord.Message, +        *, +        title: str = "Bookmark" +    ) -> None: +        """Send the author a link to `target_message` via DMs.""" +        # Prevent users from bookmarking a message in a channel they don't have access to +        permissions = ctx.author.permissions_in(target_message.channel) +        if not permissions.read_messages: +            log.info(f"{ctx.author} tried to bookmark a message in #{target_message.channel} but has no permissions") +            embed = discord.Embed( +                title=random.choice(ERROR_REPLIES), +                color=Colours.soft_red, +                description="You don't have permission to view this channel." +            ) +            await ctx.send(embed=embed) +            return + +        embed = discord.Embed( +            title=title, +            colour=Colours.soft_green, +            description=target_message.content +        ) +        embed.add_field(name="Wanna give it a visit?", value=f"[Visit original message]({target_message.jump_url})") +        embed.set_author(name=target_message.author, icon_url=target_message.author.avatar_url) +        embed.set_thumbnail(url=bookmark_icon_url) + +        try: +            await ctx.author.send(embed=embed) +        except discord.Forbidden: +            error_embed = discord.Embed( +                title=random.choice(ERROR_REPLIES), +                description=f"{ctx.author.mention}, please enable your DMs to receive the bookmark", +                colour=Colours.soft_red +            ) +            await ctx.send(embed=error_embed) +        else: +            log.info(f"{ctx.author} bookmarked {target_message.jump_url} with title '{title}'") +            await ctx.message.add_reaction(Emojis.envelope) + + +def setup(bot: commands.Bot) -> None: +    """Load the Bookmark cog.""" +    bot.add_cog(Bookmark(bot)) +    log.info("Bookmark cog loaded") diff --git a/bot/seasons/evergreen/game.py b/bot/seasons/evergreen/game.py new file mode 100644 index 00000000..e6700937 --- /dev/null +++ b/bot/seasons/evergreen/game.py @@ -0,0 +1,395 @@ +import difflib +import logging +import random +from datetime import datetime as dt +from enum import IntEnum +from typing import Any, Dict, List, Optional, Tuple + +from aiohttp import ClientSession +from discord import Embed +from discord.ext import tasks +from discord.ext.commands import Cog, Context, group + +from bot.bot import SeasonalBot +from bot.constants import STAFF_ROLES, Tokens +from bot.decorators import with_role +from bot.pagination import ImagePaginator, LinePaginator + +# Base URL of IGDB API +BASE_URL = "https://api-v3.igdb.com" + +HEADERS = { +    "user-key": Tokens.igdb, +    "Accept": "application/json" +} + +logger = logging.getLogger(__name__) + +# --------- +# TEMPLATES +# --------- + +# Body templates +# Request body template for get_games_list +GAMES_LIST_BODY = ( +    "fields cover.image_id, first_release_date, total_rating, name, storyline, url, platforms.name, status," +    "involved_companies.company.name, summary, age_ratings.category, age_ratings.rating, total_rating_count;" +    "{sort} {limit} {offset} {genre} {additional}" +) + +# Request body template for get_companies_list +COMPANIES_LIST_BODY = ( +    "fields name, url, start_date, logo.image_id, developed.name, published.name, description;" +    "offset {offset}; limit {limit};" +) + +# Request body template for games search +SEARCH_BODY = 'fields name, url, storyline, total_rating, total_rating_count; limit 50; search "{term}";' + +# Pages templates +# Game embed layout +GAME_PAGE = ( +    "**[{name}]({url})**\n" +    "{description}" +    "**Release Date:** {release_date}\n" +    "**Rating:** {rating}/100 :star: (based on {rating_count} ratings)\n" +    "**Platforms:** {platforms}\n" +    "**Status:** {status}\n" +    "**Age Ratings:** {age_ratings}\n" +    "**Made by:** {made_by}\n\n" +    "{storyline}" +) + +# .games company command page layout +COMPANY_PAGE = ( +    "**[{name}]({url})**\n" +    "{description}" +    "**Founded:** {founded}\n" +    "**Developed:** {developed}\n" +    "**Published:** {published}" +) + +# For .games search command line layout +GAME_SEARCH_LINE = ( +    "**[{name}]({url})**\n" +    "{rating}/100 :star: (based on {rating_count} ratings)\n" +) + +# URL templates +COVER_URL = "https://images.igdb.com/igdb/image/upload/t_cover_big/{image_id}.jpg" +LOGO_URL = "https://images.igdb.com/igdb/image/upload/t_logo_med/{image_id}.png" + +# Create aliases for complex genre names +ALIASES = { +    "Role-playing (rpg)": ["Role playing", "Rpg"], +    "Turn-based strategy (tbs)": ["Turn based strategy", "Tbs"], +    "Real time strategy (rts)": ["Real time strategy", "Rts"], +    "Hack and slash/beat 'em up": ["Hack and slash"] +} + + +class GameStatus(IntEnum): +    """Game statuses in IGDB API.""" + +    Released = 0 +    Alpha = 2 +    Beta = 3 +    Early = 4 +    Offline = 5 +    Cancelled = 6 +    Rumored = 7 + + +class AgeRatingCategories(IntEnum): +    """IGDB API Age Rating categories IDs.""" + +    ESRB = 1 +    PEGI = 2 + + +class AgeRatings(IntEnum): +    """PEGI/ESRB ratings IGDB API IDs.""" + +    Three = 1 +    Seven = 2 +    Twelve = 3 +    Sixteen = 4 +    Eighteen = 5 +    RP = 6 +    EC = 7 +    E = 8 +    E10 = 9 +    T = 10 +    M = 11 +    AO = 12 + + +class Games(Cog): +    """Games Cog contains commands that collect data from IGDB.""" + +    def __init__(self, bot: SeasonalBot): +        self.bot = bot +        self.http_session: ClientSession = bot.http_session + +        self.genres: Dict[str, int] = {} + +        self.refresh_genres_task.start() + +    @tasks.loop(hours=1.0) +    async def refresh_genres_task(self) -> None: +        """Refresh genres in every hour.""" +        try: +            await self._get_genres() +        except Exception as e: +            logger.warning(f"There was error while refreshing genres: {e}") +            return +        logger.info("Successfully refreshed genres.") + +    def cog_unload(self) -> None: +        """Cancel genres refreshing start when unloading Cog.""" +        self.refresh_genres_task.cancel() +        logger.info("Successfully stopped Genres Refreshing task.") + +    async def _get_genres(self) -> None: +        """Create genres variable for games command.""" +        body = "fields name; limit 100;" +        async with self.http_session.get(f"{BASE_URL}/genres", data=body, headers=HEADERS) as resp: +            result = await resp.json() + +        genres = {genre["name"].capitalize(): genre["id"] for genre in result} + +        # Replace complex names with names from ALIASES +        for genre_name, genre in genres.items(): +            if genre_name in ALIASES: +                for alias in ALIASES[genre_name]: +                    self.genres[alias] = genre +            else: +                self.genres[genre_name] = genre + +    @group(name="games", aliases=["game"], invoke_without_command=True) +    async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str] = None) -> None: +        """ +        Get random game(s) by genre from IGDB. Use .games genres command to get all available genres. + +        Also support amount parameter, what max is 25 and min 1, default 5. Supported formats: +        - .games <genre> +        - .games <amount> <genre> +        """ +        # When user didn't specified genre, send help message +        if genre is None: +            await ctx.send_help("games") +            return + +        # Capitalize genre for check +        genre = "".join(genre).capitalize() + +        # Check for amounts, max is 25 and min 1 +        if not 1 <= amount <= 25: +            await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") +            return + +        # Get games listing, if genre don't exist, show error message with possibilities. +        # Offset must be random, due otherwise we will get always same result (offset show in which position should +        # API start returning result) +        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 + +        # Create pages and paginate +        pages = [await self.create_page(game) for game in games] + +        await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games")) + +    @games.command(name="top", aliases=["t"]) +    async def top(self, ctx: Context, amount: int = 10) -> None: +        """ +        Get current Top games in IGDB. + +        Support amount parameter. Max is 25, min is 1. +        """ +        if not 1 <= amount <= 25: +            await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") +            return + +        games = await self.get_games_list(amount, sort="total_rating desc", +                                          additional_body="where total_rating >= 90; sort total_rating_count desc;") + +        pages = [await self.create_page(game) for game in games] +        await ImagePaginator.paginate(pages, ctx, Embed(title=f"Top {amount} Games")) + +    @games.command(name="genres", aliases=["genre", "g"]) +    async def genres(self, ctx: Context) -> None: +        """Get all available genres.""" +        await ctx.send(f"Currently available genres: {', '.join(f'`{genre}`' for genre in self.genres)}") + +    @games.command(name="search", aliases=["s"]) +    async def search(self, ctx: Context, *, search_term: str) -> None: +        """Find games by name.""" +        lines = await self.search_games(search_term) + +        await LinePaginator.paginate(lines, ctx, Embed(title=f"Game Search Results: {search_term}"), empty=False) + +    @games.command(name="company", aliases=["companies"]) +    async def company(self, ctx: Context, amount: int = 5) -> None: +        """ +        Get random Game Companies companies from IGDB API. + +        Support amount parameter. Max is 25, min is 1. +        """ +        if not 1 <= amount <= 25: +            await ctx.send("Your provided amount is out of range. Our minimum is 1 and maximum 25.") +            return + +        # Get companies listing. Provide limit for limiting how much companies will be returned. Get random offset to +        # get (almost) every time different companies (offset show in which position should API start returning result) +        companies = await self.get_companies_list(limit=amount, offset=random.randint(0, 150)) +        pages = [await self.create_company_page(co) for co in companies] + +        await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies")) + +    @with_role(*STAFF_ROLES) +    @games.command(name="refresh", aliases=["r"]) +    async def refresh_genres_command(self, ctx: Context) -> None: +        """Refresh .games command genres.""" +        try: +            await self._get_genres() +        except Exception as e: +            await ctx.send(f"There was error while refreshing genres: `{e}`") +            return +        await ctx.send("Successfully refreshed genres.") + +    async def get_games_list(self, +                             amount: int, +                             genre: Optional[str] = None, +                             sort: Optional[str] = None, +                             additional_body: str = "", +                             offset: int = 0 +                             ) -> List[Dict[str, Any]]: +        """ +        Get list of games from IGDB API by parameters that is provided. + +        Amount param show how much games this get, genre is genre ID and at least one genre in game must this when +        provided. Sort is sorting by specific field and direction, ex. total_rating desc/asc (total_rating is field, +        desc/asc is direction). Additional_body is field where you can pass extra search parameters. Offset show start +        position in API. +        """ +        # Create body of IGDB API request, define fields, sorting, offset, limit and genre +        params = { +            "sort": f"sort {sort};" if sort else "", +            "limit": f"limit {amount};", +            "offset": f"offset {offset};" if offset else "", +            "genre": f"where genres = ({genre});" if genre else "", +            "additional": additional_body +        } +        body = GAMES_LIST_BODY.format(**params) + +        # Do request to IGDB API, create headers, URL, define body, return result +        async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: +            return await resp.json() + +    async def create_page(self, data: Dict[str, Any]) -> Tuple[str, str]: +        """Create content of Game Page.""" +        # Create cover image URL from template +        url = COVER_URL.format(**{"image_id": data["cover"]["image_id"] if "cover" in data else ""}) + +        # Get release date separately with checking +        release_date = dt.utcfromtimestamp(data["first_release_date"]).date() if "first_release_date" in data else "?" + +        # Create Age Ratings value +        rating = ", ".join(f"{AgeRatingCategories(age['category']).name} {AgeRatings(age['rating']).name}" +                           for age in data["age_ratings"]) if "age_ratings" in data else "?" + +        companies = [c["company"]["name"] for c in data["involved_companies"]] if "involved_companies" in data else "?" + +        # Create formatting for template page +        formatting = { +            "name": data["name"], +            "url": data["url"], +            "description": f"{data['summary']}\n\n" if "summary" in data else "\n", +            "release_date": release_date, +            "rating": round(data["total_rating"] if "total_rating" in data else 0, 2), +            "rating_count": data["total_rating_count"] if "total_rating_count" in data else "?", +            "platforms": ", ".join(platform["name"] for platform in data["platforms"]) if "platforms" in data else "?", +            "status": GameStatus(data["status"]).name if "status" in data else "?", +            "age_ratings": rating, +            "made_by": ", ".join(companies), +            "storyline": data["storyline"] if "storyline" in data else "" +        } +        page = GAME_PAGE.format(**formatting) + +        return page, url + +    async def search_games(self, search_term: str) -> List[str]: +        """Search game from IGDB API by string, return listing of pages.""" +        lines = [] + +        # Define request body of IGDB API request and do request +        body = SEARCH_BODY.format(**{"term": search_term}) + +        async with self.http_session.get(url=f"{BASE_URL}/games", data=body, headers=HEADERS) as resp: +            data = await resp.json() + +        # Loop over games, format them to good format, make line and append this to total lines +        for game in data: +            formatting = { +                "name": game["name"], +                "url": game["url"], +                "rating": round(game["total_rating"] if "total_rating" in game else 0, 2), +                "rating_count": game["total_rating_count"] if "total_rating" in game else "?" +            } +            line = GAME_SEARCH_LINE.format(**formatting) +            lines.append(line) + +        return lines + +    async def get_companies_list(self, limit: int, offset: int = 0) -> List[Dict[str, Any]]: +        """ +        Get random Game Companies from IGDB API. + +        Limit is parameter, that show how much movies this should return, offset show in which position should API start +        returning results. +        """ +        # Create request body from template +        body = COMPANIES_LIST_BODY.format(**{ +            "limit": limit, +            "offset": offset +        }) + +        async with self.http_session.get(url=f"{BASE_URL}/companies", data=body, headers=HEADERS) as resp: +            return await resp.json() + +    async def create_company_page(self, data: Dict[str, Any]) -> Tuple[str, str]: +        """Create good formatted Game Company page.""" +        # Generate URL of company logo +        url = LOGO_URL.format(**{"image_id": data["logo"]["image_id"] if "logo" in data else ""}) + +        # Try to get found date of company +        founded = dt.utcfromtimestamp(data["start_date"]).date() if "start_date" in data else "?" + +        # Generate list of games, that company have developed or published +        developed = ", ".join(game["name"] for game in data["developed"]) if "developed" in data else "?" +        published = ", ".join(game["name"] for game in data["published"]) if "published" in data else "?" + +        formatting = { +            "name": data["name"], +            "url": data["url"], +            "description": f"{data['description']}\n\n" if "description" in data else "\n", +            "founded": founded, +            "developed": developed, +            "published": published +        } +        page = COMPANY_PAGE.format(**formatting) + +        return page, url + + +def setup(bot: SeasonalBot) -> None: +    """Add/Load Games cog.""" +    # Check does IGDB API key exist, if not, log warning and don't load cog +    if not Tokens.igdb: +        logger.warning("No IGDB API key. Not loading Games cog.") +        return +    bot.add_cog(Games(bot)) diff --git a/bot/seasons/evergreen/issues.py b/bot/seasons/evergreen/issues.py index c7501a5d..fba5b174 100644 --- a/bot/seasons/evergreen/issues.py +++ b/bot/seasons/evergreen/issues.py @@ -3,11 +3,10 @@ import logging  import discord  from discord.ext import commands -from bot.constants import Channels, Colours, Emojis, WHITELISTED_CHANNELS +from bot.constants import Colours, Emojis, WHITELISTED_CHANNELS  from bot.decorators import override_in_channel  log = logging.getLogger(__name__) -ISSUE_WHITELIST = WHITELISTED_CHANNELS + (Channels.seasonalbot_chat,)  BAD_RESPONSE = {      404: "Issue/pull request not located! Please enter a valid number!", @@ -22,7 +21,7 @@ class Issues(commands.Cog):          self.bot = bot      @commands.command(aliases=("pr",)) -    @override_in_channel(ISSUE_WHITELIST) +    @override_in_channel(WHITELISTED_CHANNELS)      async def issue(          self, ctx: commands.Context, number: int, repository: str = "seasonalbot", user: str = "python-discord"      ) -> None: diff --git a/bot/seasons/evergreen/movie.py b/bot/seasons/evergreen/movie.py new file mode 100644 index 00000000..3c5a312d --- /dev/null +++ b/bot/seasons/evergreen/movie.py @@ -0,0 +1,198 @@ +import logging +import random +from enum import Enum +from typing import Any, Dict, List, Tuple +from urllib.parse import urlencode + +from aiohttp import ClientSession +from discord import Embed +from discord.ext.commands import Bot, Cog, Context, group + +from bot.constants import Tokens +from bot.pagination import ImagePaginator + +# Define base URL of TMDB +BASE_URL = "https://api.themoviedb.org/3/" + +logger = logging.getLogger(__name__) + +# Define movie params, that will be used for every movie request +MOVIE_PARAMS = { +    "api_key": Tokens.tmdb, +    "language": "en-US" +} + + +class MovieGenres(Enum): +    """Movies Genre names and IDs.""" + +    Action = "28" +    Adventure = "12" +    Animation = "16" +    Comedy = "35" +    Crime = "80" +    Documentary = "99" +    Drama = "18" +    Family = "10751" +    Fantasy = "14" +    History = "36" +    Horror = "27" +    Music = "10402" +    Mystery = "9648" +    Romance = "10749" +    Science = "878" +    Thriller = "53" +    Western = "37" + + +class Movie(Cog): +    """Movie Cog contains movies command that grab random movies from TMDB.""" + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.http_session: ClientSession = bot.http_session + +    @group(name='movies', aliases=['movie'], invoke_without_command=True) +    async def movies(self, ctx: Context, genre: str = "", amount: int = 5) -> None: +        """ +        Get random movies by specifying genre. Also support amount parameter, that define how much movies will be shown. + +        Default 5. Use .movies genres to get all available genres. +        """ +        # Check is there more than 20 movies specified, due TMDB return 20 movies +        # per page, so this is max. Also you can't get less movies than 1, just logic +        if amount > 20: +            await ctx.send("You can't get more than 20 movies at once. (TMDB limits)") +            return +        elif amount < 1: +            await ctx.send("You can't get less than 1 movie.") +            return + +        # Capitalize genre for getting data from Enum, get random page, send help when genre don't exist. +        genre = genre.capitalize() +        try: +            result = await self.get_movies_list(self.http_session, MovieGenres[genre].value, 1) +        except KeyError: +            await ctx.send_help('movies') +            return + +        # Check if "results" is in result. If not, throw error. +        if "results" not in result.keys(): +            err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ +                      f"{result['status_message']}." +            await ctx.send(err_msg) +            logger.warning(err_msg) + +        # Get random page. Max page is last page where is movies with this genre. +        page = random.randint(1, result["total_pages"]) + +        # Get movies list from TMDB, check if results key in result. When not, raise error. +        movies = await self.get_movies_list(self.http_session, MovieGenres[genre].value, page) +        if 'results' not in movies.keys(): +            err_msg = f"There is problem while making TMDB API request. Response Code: {result['status_code']}, " \ +                      f"{result['status_message']}." +            await ctx.send(err_msg) +            logger.warning(err_msg) + +        # Get all pages and embed +        pages = await self.get_pages(self.http_session, movies, amount) +        embed = await self.get_embed(genre) + +        await ImagePaginator.paginate(pages, ctx, embed) + +    @movies.command(name='genres', aliases=['genre', 'g']) +    async def genres(self, ctx: Context) -> None: +        """Show all currently available genres for .movies command.""" +        await ctx.send(f"Current available genres: {', '.join('`' + genre.name + '`' for genre in MovieGenres)}") + +    async def get_movies_list(self, client: ClientSession, genre_id: str, page: int) -> Dict[str, Any]: +        """Return JSON of TMDB discover request.""" +        # Define params of request +        params = { +            "api_key": Tokens.tmdb, +            "language": "en-US", +            "sort_by": "popularity.desc", +            "include_adult": "false", +            "include_video": "false", +            "page": page, +            "with_genres": genre_id +        } + +        url = BASE_URL + "discover/movie?" + urlencode(params) + +        # Make discover request to TMDB, return result +        async with client.get(url) as resp: +            return await resp.json() + +    async def get_pages(self, client: ClientSession, movies: Dict[str, Any], amount: int) -> List[Tuple[str, str]]: +        """Fetch all movie pages from movies dictionary. Return list of pages.""" +        pages = [] + +        for i in range(amount): +            movie_id = movies['results'][i]['id'] +            movie = await self.get_movie(client, movie_id) + +            page, img = await self.create_page(movie) +            pages.append((page, img)) + +        return pages + +    async def get_movie(self, client: ClientSession, movie: int) -> Dict: +        """Get Movie by movie ID from TMDB. Return result dictionary.""" +        url = BASE_URL + f"movie/{movie}?" + urlencode(MOVIE_PARAMS) + +        async with client.get(url) as resp: +            return await resp.json() + +    async def create_page(self, movie: Dict[str, Any]) -> Tuple[str, str]: +        """Create page from TMDB movie request result. Return formatted page + image.""" +        text = "" + +        # Add title + tagline (if not empty) +        text += f"**{movie['title']}**\n" +        if movie['tagline']: +            text += f"{movie['tagline']}\n\n" +        else: +            text += "\n" + +        # Add other information +        text += f"**Rating:** {movie['vote_average']}/10 :star:\n" +        text += f"**Release Date:** {movie['release_date']}\n\n" + +        text += "__**Production Information**__\n" + +        companies = movie['production_companies'] +        countries = movie['production_countries'] + +        text += f"**Made by:** {', '.join(company['name'] for company in companies)}\n" +        text += f"**Made in:** {', '.join(country['name'] for country in countries)}\n\n" + +        text += "__**Some Numbers**__\n" + +        budget = f"{movie['budget']:,d}" if movie['budget'] else "?" +        revenue = f"{movie['revenue']:,d}" if movie['revenue'] else "?" + +        if movie['runtime'] is not None: +            duration = divmod(movie['runtime'], 60) +        else: +            duration = ("?", "?") + +        text += f"**Budget:** ${budget}\n" +        text += f"**Revenue:** ${revenue}\n" +        text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n" + +        text += movie['overview'] + +        img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}" + +        # Return page content and image +        return text, img + +    async def get_embed(self, name: str) -> Embed: +        """Return embed of random movies. Uses name in title.""" +        return Embed(title=f'Random {name} Movies').set_footer(text='Powered by TMDB (themoviedb.org)') + + +def setup(bot: Bot) -> None: +    """Load Movie Cog.""" +    bot.add_cog(Movie(bot)) diff --git a/bot/seasons/evergreen/reddit.py b/bot/seasons/evergreen/reddit.py new file mode 100644 index 00000000..32ca419a --- /dev/null +++ b/bot/seasons/evergreen/reddit.py @@ -0,0 +1,130 @@ +import logging +import random + +import discord +from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType + +from bot.pagination import ImagePaginator + + +log = logging.getLogger(__name__) + + +class Reddit(commands.Cog): +    """Fetches reddit posts.""" + +    def __init__(self, bot: commands.Bot): +        self.bot = bot + +    async def fetch(self, url: str) -> dict: +        """Send a get request to the reddit API and get json response.""" +        session = self.bot.http_session +        params = { +            'limit': 50 +        } +        headers = { +            'User-Agent': 'Iceman' +        } + +        async with session.get(url=url, params=params, headers=headers) as response: +            return await response.json() + +    @commands.command(name='reddit') +    @commands.cooldown(1, 10, BucketType.user) +    async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None: +        """ +        Fetch reddit posts by using this command. + +        Gets a post from r/python by default. +        Usage: +        --> .reddit [subreddit_name] [hot/top/new] +        """ +        pages = [] +        sort_list = ["hot", "new", "top", "rising"] +        if sort.lower() not in sort_list: +            await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`") +            sort = "hot" + +        data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json') + +        try: +            posts = data["data"]["children"] +        except KeyError: +            return await ctx.send('Subreddit not found!') +        if not posts: +            return await ctx.send('No posts available!') + +        if posts[1]["data"]["over_18"] is True: +            return await ctx.send( +                "You cannot access this Subreddit as it is ment for those who " +                "are 18 years or older." +            ) + +        embed_titles = "" + +        # Chooses k unique random elements from a population sequence or set. +        random_posts = random.sample(posts, k=5) + +        # ----------------------------------------------------------- +        # This code below is bound of change when the emojis are added. + +        upvote_emoji = self.bot.get_emoji(638729835245731840) +        comment_emoji = self.bot.get_emoji(638729835073765387) +        user_emoji = self.bot.get_emoji(638729835442602003) +        text_emoji = self.bot.get_emoji(676030265910493204) +        video_emoji = self.bot.get_emoji(676030265839190047) +        image_emoji = self.bot.get_emoji(676030265734201344) +        reddit_emoji = self.bot.get_emoji(676030265734332427) + +        # ------------------------------------------------------------ + +        for i, post in enumerate(random_posts, start=1): +            post_title = post["data"]["title"][0:50] +            post_url = post['data']['url'] +            if post_title == "": +                post_title = "No Title." +            elif post_title == post_url: +                post_title = "Title is itself a link." + +            # ------------------------------------------------------------------ +            # Embed building. + +            embed_titles += f"**{i}.[{post_title}]({post_url})**\n" +            image_url = " " +            post_stats = f"{text_emoji}"  # Set default content type to text. + +            if post["data"]["is_video"] is True or "youtube" in post_url.split("."): +                # This means the content type in the post is a video. +                post_stats = f"{video_emoji} " + +            elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"): +                # This means the content type in the post is an image. +                post_stats = f"{image_emoji} " +                image_url = post_url + +            votes = f'{upvote_emoji}{post["data"]["ups"]}' +            comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}' +            post_stats += ( +                f"\u2002{votes}\u2003" +                f"{comments}" +                f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n' +            ) +            embed_titles += f"{post_stats}\n" +            page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}" + +            embed = discord.Embed() +            page_tuple = (page_text, image_url) +            pages.append(page_tuple) + +            # ------------------------------------------------------------------ + +        pages.insert(0, (embed_titles, " ")) +        embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url) +        await ImagePaginator.paginate(pages, ctx, embed) + + +def setup(bot: commands.Bot) -> None: +    """Load the Cog.""" +    bot.add_cog(Reddit(bot)) +    log.debug('Loaded') diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py index 1ed38f86..09f5e250 100644 --- a/bot/seasons/evergreen/snakes/snakes_cog.py +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -617,8 +617,8 @@ class Snakes(Cog):                  text_color=text_color,                  bg_color=bg_color              ) -            png_bytesIO = utils.frame_to_png_bytes(image_frame) -            file = File(png_bytesIO, filename='snek.png') +            png_bytes = utils.frame_to_png_bytes(image_frame) +            file = File(png_bytes, filename='snek.png')              await ctx.send(file=file)      @snakes_group.command(name='get') diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 64da7ced..490609dd 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -41,7 +41,7 @@ class CandyCollection(commands.Cog):          if message.author.bot:              return          # ensure it's hacktober channel -        if message.channel.id != Channels.seasonalbot_chat: +        if message.channel.id != Channels.seasonalbot_commands:              return          # do random check for skull first as it has the lower chance @@ -64,7 +64,7 @@ class CandyCollection(commands.Cog):              return          # check to ensure it is in correct channel -        if message.channel.id != Channels.seasonalbot_chat: +        if message.channel.id != Channels.seasonalbot_commands:              return          # if its not a candy or skull, and it is one of 10 most recent messages, @@ -124,7 +124,7 @@ class CandyCollection(commands.Cog):          ten_recent = []          recent_msg_id = max(              message.id for message in self.bot._connection._messages -            if message.channel.id == Channels.seasonalbot_chat +            if message.channel.id == Channels.seasonalbot_commands          )          channel = await self.hacktober_channel() @@ -155,7 +155,7 @@ class CandyCollection(commands.Cog):      async def hacktober_channel(self) -> discord.TextChannel:          """Get #hacktoberbot channel from its ID.""" -        return self.bot.get_channel(id=Channels.seasonalbot_chat) +        return self.bot.get_channel(id=Channels.seasonalbot_commands)      async def remove_reactions(self, reaction: discord.Reaction) -> None:          """Remove all candy/skull reactions.""" diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index b7b4122d..d61e048b 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -121,8 +121,8 @@ class HacktoberStats(commands.Cog):          """          if self.link_json.exists():              logging.info(f"Loading linked GitHub accounts from '{self.link_json}'") -            with open(self.link_json, 'r') as fID: -                linked_accounts = json.load(fID) +            with open(self.link_json, 'r') as file: +                linked_accounts = json.load(file)              logging.info(f"Loaded {len(linked_accounts)} linked GitHub accounts from '{self.link_json}'")              return linked_accounts @@ -143,8 +143,8 @@ class HacktoberStats(commands.Cog):              }          """          logging.info(f"Saving linked_accounts to '{self.link_json}'") -        with open(self.link_json, 'w') as fID: -            json.dump(self.linked_accounts, fID, default=str) +        with open(self.link_json, 'w') as file: +            json.dump(self.linked_accounts, file, default=str)          logging.info(f"linked_accounts saved to '{self.link_json}'")      async def get_stats(self, ctx: commands.Context, github_username: str) -> None: @@ -309,11 +309,11 @@ class HacktoberStats(commands.Cog):             n contribution(s) to [shortname](url)             ...          """ -        baseURL = "https://www.github.com/" +        base_url = "https://www.github.com/"          contributionstrs = []          for repo in stats['top5']:              n = repo[1] -            contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({baseURL}{repo[0]})") +            contributionstrs.append(f"{n} {HacktoberStats._contributionator(n)} to [{repo[0]}]({base_url}{repo[0]})")          return "\n".join(contributionstrs) @@ -334,7 +334,7 @@ class HacktoberStats(commands.Cog):          return author_id, author_mention -def setup(bot):  # Noqa +def setup(bot: commands.Bot) -> None:      """Hacktoberstats Cog load."""      bot.add_cog(HacktoberStats(bot))      log.info("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index f8610bd3..94730d9e 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -40,7 +40,7 @@ class HalloweenFacts(commands.Cog):      @commands.Cog.listener()      async def on_ready(self) -> None:          """Get event Channel object and initialize fact task loop.""" -        self.channel = self.bot.get_channel(Channels.seasonalbot_chat) +        self.channel = self.bot.get_channel(Channels.seasonalbot_commands)          self.bot.loop.create_task(self._fact_publisher_task())      def random_fact(self) -> Tuple[int, str]: diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py index 75e90b2a..08df2fa1 100644 --- a/bot/seasons/pride/__init__.py +++ b/bot/seasons/pride/__init__.py @@ -16,7 +16,7 @@ class Pride(SeasonBase):      • [Pride issues are now available for SeasonalBot on the repo](https://git.io/pythonpride).      • You may see Pride-themed esoteric challenges and other microevents. -    If you'd like to contribute, head on over to <#542272993192050698> and we will help you get +    If you'd like to contribute, head on over to <#635950537262759947> and we will help you get      started. It doesn't matter if you're new to open source or Python, if you'd like to help, we      will find you a task and teach you what you need to know.      """ diff --git a/bot/seasons/pride/pride_facts.py b/bot/seasons/pride/pride_facts.py index b705bfb4..5c19dfd0 100644 --- a/bot/seasons/pride/pride_facts.py +++ b/bot/seasons/pride/pride_facts.py @@ -33,7 +33,7 @@ class PrideFacts(commands.Cog):      async def send_pride_fact_daily(self) -> None:          """Background task to post the daily pride fact every day.""" -        channel = self.bot.get_channel(Channels.seasonalbot_chat) +        channel = self.bot.get_channel(Channels.seasonalbot_commands)          while True:              await self.send_select_fact(channel, datetime.utcnow())              await asyncio.sleep(24 * 60 * 60) diff --git a/bot/seasons/season.py b/bot/seasons/season.py index e7b7a69c..763a08d2 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -383,18 +383,29 @@ class SeasonManager(commands.Cog):          """Asynchronous timer loop to check for a new season every midnight."""          await self.bot.wait_until_ready()          await self.season.load() +        days_since_icon_change = 0          while True:              await asyncio.sleep(self.sleep_time)  # Sleep until midnight -            self.sleep_time = 86400  # Next time, sleep for 24 hours. +            self.sleep_time = 24 * 3600  # Next time, sleep for 24 hours + +            days_since_icon_change += 1 +            log.debug(f"Days since last icon change: {days_since_icon_change}")              # If the season has changed, load it.              new_season = get_season(date=datetime.datetime.utcnow())              if new_season.name != self.season.name:                  self.season = new_season                  await self.season.load() +                days_since_icon_change = 0  # Start counting afresh for the new season + +            # Otherwise we check whether it's time for an icon cycle within the current season              else: -                await self.season.change_server_icon() +                if days_since_icon_change == Client.icon_cycle_frequency: +                    await self.season.change_server_icon() +                    days_since_icon_change = 0 +                else: +                    log.debug(f"Waiting {Client.icon_cycle_frequency - days_since_icon_change} more days to cycle icon")      @with_role(Roles.moderator, Roles.admin, Roles.owner)      @commands.command(name="season") diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py index a073e1bd..ab8ea290 100644 --- a/bot/seasons/valentines/be_my_valentine.py +++ b/bot/seasons/valentines/be_my_valentine.py @@ -96,7 +96,7 @@ class BeMyValentine(commands.Cog):          emoji_1, emoji_2 = self.random_emoji()          lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) -        channel = self.bot.get_channel(Channels.seasonalbot_chat) +        channel = self.bot.get_channel(Channels.seasonalbot_commands)          valentine, title = self.valentine_check(valentine_type)          if user is None: @@ -202,9 +202,9 @@ class BeMyValentine(commands.Cog):      @staticmethod      def random_emoji() -> Tuple[str, str]:          """Return two random emoji from the module-defined constants.""" -        EMOJI_1 = random.choice(HEART_EMOJIS) -        EMOJI_2 = random.choice(HEART_EMOJIS) -        return EMOJI_1, EMOJI_2 +        emoji_1 = random.choice(HEART_EMOJIS) +        emoji_2 = random.choice(HEART_EMOJIS) +        return emoji_1, emoji_2      def random_valentine(self) -> Tuple[str, str]:          """Grabs a random poem or a compliment (any message)."""  |