diff options
| author | 2021-05-16 22:09:19 +0530 | |
|---|---|---|
| committer | 2021-05-16 22:09:19 +0530 | |
| commit | ab3cf464883d30c5191e3b40328ead90dc518d12 (patch) | |
| tree | f46d89e35f8fa651a98ea4427f20962145fe91ed /bot | |
| parent | add check for statistics (diff) | |
| parent | Spring cleanup (#718) (diff) | |
Merge branch 'main' into feature/stackoverflow
Diffstat (limited to 'bot')
117 files changed, 3360 insertions, 2079 deletions
| diff --git a/bot/__init__.py b/bot/__init__.py index bdb18666..e5ed9d92 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,11 +2,15 @@ import asyncio  import logging  import logging.handlers  import os +from functools import partial, partialmethod  from pathlib import Path  import arrow +from discord.ext import commands +from bot.command import Command  from bot.constants import Client +from bot.group import Group  # Configure the "TRACE" logging level (e.g. "log.trace(message)") @@ -56,17 +60,27 @@ if root.handlers:  logging.getLogger("discord").setLevel(logging.ERROR)  logging.getLogger("websockets").setLevel(logging.ERROR)  logging.getLogger("PIL").setLevel(logging.ERROR) +logging.getLogger("matplotlib").setLevel(logging.ERROR)  # Setup new logging configuration  logging.basicConfig( -    format='%(asctime)s - %(name)s %(levelname)s: %(message)s', +    format="%(asctime)s - %(name)s %(levelname)s: %(message)s",      datefmt="%D %H:%M:%S",      level=logging.TRACE if Client.debug else logging.DEBUG,      handlers=[console_handler, file_handler],  ) -logging.getLogger().info('Logging initialization complete') +logging.getLogger().info("Logging initialization complete")  # On Windows, the selector event loop is required for aiodns.  if os.name == "nt":      asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) + +commands.group = partial(commands.group, cls=Group) +commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=Group) @@ -64,6 +64,26 @@ class Bot(commands.Bot):          super().add_cog(cog)          log.info(f"Cog loaded: {cog.qualified_name}") +    def add_command(self, command: commands.Command) -> None: +        """Add `command` as normal and then add its root aliases to the bot.""" +        super().add_command(command) +        self._add_root_aliases(command) + +    def remove_command(self, name: str) -> Optional[commands.Command]: +        """ +        Remove a command/alias as normal and then remove its root aliases from the bot. + +        Individual root aliases cannot be removed by this function. +        To remove them, either remove the entire command or manually edit `bot.all_commands`. +        """ +        command = super().remove_command(name) +        if command is None: +            # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. +            return + +        self._remove_root_aliases(command) +        return command +      async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None:          """Check command errors for UserInputError and reset the cooldown if thrown."""          if isinstance(exception, commands.UserInputError): @@ -81,7 +101,7 @@ class Bot(commands.Bot):          all_channels_ids = [channel.id for channel in self.get_all_channels()]          for name, channel_id in vars(constants.Channels).items(): -            if name.startswith('_'): +            if name.startswith("_"):                  continue              if channel_id not in all_channels_ids:                  log.error(f'Channel "{name}" with ID {channel_id} missing') @@ -139,6 +159,27 @@ class Bot(commands.Bot):          """          await self._guild_available.wait() +    def _add_root_aliases(self, command: commands.Command) -> None: +        """Recursively add root aliases for `command` and any of its subcommands.""" +        if isinstance(command, commands.Group): +            for subcommand in command.commands: +                self._add_root_aliases(subcommand) + +        for alias in getattr(command, "root_aliases", ()): +            if alias in self.all_commands: +                raise commands.CommandRegistrationError(alias, alias_conflict=True) + +            self.all_commands[alias] = command + +    def _remove_root_aliases(self, command: commands.Command) -> None: +        """Recursively remove root aliases for `command` and any of its subcommands.""" +        if isinstance(command, commands.Group): +            for subcommand in command.commands: +                self._remove_root_aliases(subcommand) + +        for alias in getattr(command, "root_aliases", ()): +            self.all_commands.pop(alias, None) +  _allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 00000000..0fb900f7 --- /dev/null +++ b/bot/command.py @@ -0,0 +1,18 @@ +from discord.ext import commands + + +class Command(commands.Command): +    """ +    A `discord.ext.commands.Command` subclass which supports root aliases. + +    A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as +    top-level commands rather than being aliases of the command's group. It's stored as an attribute +    also named `root_aliases`. +    """ + +    def __init__(self, *args, **kwargs): +        super().__init__(*args, **kwargs) +        self.root_aliases = kwargs.get("root_aliases", []) + +        if not isinstance(self.root_aliases, (list, tuple)): +            raise TypeError("Root aliases of a command must be a list or a tuple of strings.") diff --git a/bot/constants.py b/bot/constants.py index 49999725..37be84b6 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -8,6 +8,7 @@ from typing import Dict, NamedTuple  __all__ = (      "AdventOfCode",      "Branding", +    "Cats",      "Channels",      "Categories",      "Client", @@ -19,6 +20,7 @@ __all__ = (      "Roles",      "Tokens",      "Wolfram", +    "Reddit",      "RedisConfig",      "MODERATION_ROLES",      "STAFF_ROLES", @@ -93,6 +95,10 @@ class Branding:      cycle_frequency = int(environ.get("CYCLE_FREQUENCY", 3))  # 0: never, 1: every day, 2: every other day, ... +class Cats: +    cats = ["ᓚᘏᗢ", "ᘡᘏᗢ", "🐈", "ᓕᘏᗢ", "ᓇᘏᗢ", "ᓂᘏᗢ", "ᘣᘏᗢ", "ᕦᘏᗢ", "ᕂᘏᗢ"] + +  class Channels(NamedTuple):      advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306))      advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 607247579608121354)) @@ -110,6 +116,7 @@ class Channels(NamedTuple):      voice_chat_0 = 412357430186344448      voice_chat_1 = 799647045886541885      staff_voice = 541638762007101470 +    reddit = int(environ.get("CHANNEL_REDDIT", 458224812528238616))  class Categories(NamedTuple): @@ -147,8 +154,25 @@ class Colours:      python_yellow = 0xFFD43B      grass_green = 0x66ff00 +    easter_like_colours = [ +        (255, 247, 0), +        (255, 255, 224), +        (0, 255, 127), +        (189, 252, 201), +        (255, 192, 203), +        (255, 160, 122), +        (181, 115, 220), +        (221, 160, 221), +        (200, 162, 200), +        (238, 130, 238), +        (135, 206, 235), +        (0, 204, 204), +        (64, 224, 208), +    ] +  class Emojis: +    cross_mark = "\u274C"      star = "\u2B50"      christmas_tree = "\U0001F384"      check = "\u2611" @@ -195,10 +219,20 @@ class Emojis:      status_dnd = "<:status_dnd:470326272082313216>"      status_offline = "<:status_offline:470326266537705472>" +      stackoverflow_upvote = environ.get('stack_upvote', "<:stack_upvote:*>")  # TODO: Fill in numbers (ids)      stackoverflow_tag = environ.get('stack_tag', "<:stack_tag:*> ")      stackoverflow_views = environ.get('stack_views', "<:stack_eye:*>") -    stackoverflow_ans = environ.get('stack_ans', "<:stack_ans:*>") +    stackoverflow_ans = environ.get('stack_ans', "<:sta + +    # Reddit emojis +    reddit = "<:reddit:676030265734332427>" +    reddit_post_text = "<:reddit_post_text:676030265910493204>" +    reddit_post_video = "<:reddit_post_video:676030265839190047>" +    reddit_post_photo = "<:reddit_post_photo:676030265734201344>" +    reddit_upvote = "<:reddit_upvote:755845219890757644>" +    reddit_comments = "<:reddit_comments:755845255001014384>" +    reddit_users = "<:reddit_users:755845303822974997>"  class Icons: @@ -276,6 +310,14 @@ class Source:      github_avatar_url = "https://avatars1.githubusercontent.com/u/9919" +class Reddit: +    subreddits = ["r/Python"] + +    client_id = environ.get("REDDIT_CLIENT_ID") +    secret = environ.get("REDDIT_SECRET") +    webhook = int(environ.get("REDDIT_WEBHOOK", 635408384794951680)) + +  # Default role combinations  MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner  STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner diff --git a/bot/exts/christmas/advent_of_code/_cog.py b/bot/exts/christmas/advent_of_code/_cog.py index 8376987d..ead84544 100644 --- a/bot/exts/christmas/advent_of_code/_cog.py +++ b/bot/exts/christmas/advent_of_code/_cog.py @@ -72,11 +72,15 @@ class AdventOfCode(commands.Cog):          if role not in ctx.author.roles:              await ctx.author.add_roles(role) -            await ctx.send("Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " -                           f"You can run `{unsubscribe_command}` to disable them again for you.") +            await ctx.send( +                "Okay! You have been __subscribed__ to notifications about new Advent of Code tasks. " +                f"You can run `{unsubscribe_command}` to disable them again for you." +            )          else: -            await ctx.send("Hey, you already are receiving notifications about new Advent of Code tasks. " -                           f"If you don't want them any more, run `{unsubscribe_command}` instead.") +            await ctx.send( +                "Hey, you already are receiving notifications about new Advent of Code tasks. " +                f"If you don't want them any more, run `{unsubscribe_command}` instead." +            )      @in_month(Month.DECEMBER)      @adventofcode_group.command(name="unsubscribe", aliases=("unsub",), brief="Notifications for new days") @@ -110,8 +114,10 @@ class AdventOfCode(commands.Cog):              else:                  delta_str = f"{delta.days} days" -            await ctx.send(f"The Advent of Code event is not currently running. " -                           f"The next event will start in {delta_str}.") +            await ctx.send( +                "The Advent of Code event is not currently running. " +                f"The next event will start in {delta_str}." +            )              return          tomorrow, time_left = _helpers.time_left_to_est_midnight() @@ -124,7 +130,7 @@ class AdventOfCode(commands.Cog):      @whitelist_override(channels=AOC_WHITELIST)      async def about_aoc(self, ctx: commands.Context) -> None:          """Respond with an explanation of all things Advent of Code.""" -        await ctx.send("", embed=self.cached_about_aoc) +        await ctx.send(embed=self.cached_about_aoc)      @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join the leaderboard (via DM)")      @whitelist_override(channels=AOC_WHITELIST) @@ -135,7 +141,7 @@ class AdventOfCode(commands.Cog):              await ctx.send(f"The Python Discord leaderboard for {current_year} is not yet available!")              return -        author = ctx.message.author +        author = ctx.author          log.info(f"{author.name} ({author.id}) has requested a PyDis AoC leaderboard code")          if AocConfig.staff_leaderboard_id and any(r.id == Roles.helpers for r in author.roles): @@ -273,8 +279,7 @@ class AdventOfCode(commands.Cog):      def _build_about_embed(self) -> discord.Embed:          """Build and return the informational "About AoC" embed from the resources file.""" -        with self.about_aoc_filepath.open("r", encoding="utf8") as f: -            embed_fields = json.load(f) +        embed_fields = json.loads(self.about_aoc_filepath.read_text("utf8"))          about_embed = discord.Embed(              title=self._base_url, diff --git a/bot/exts/christmas/advent_of_code/_helpers.py b/bot/exts/christmas/advent_of_code/_helpers.py index a16a4871..f4a258c0 100644 --- a/bot/exts/christmas/advent_of_code/_helpers.py +++ b/bot/exts/christmas/advent_of_code/_helpers.py @@ -108,7 +108,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:      # star view. We need that per star view to compute rank scores per star.      for member in raw_leaderboard_data.values():          name = member["name"] if member["name"] else f"Anonymous #{member['id']}" -        member_id = member['id'] +        member_id = member["id"]          leaderboard[member_id] = {"name": name, "score": 0, "star_1": 0, "star_2": 0}          # Iterate over all days for this participant @@ -119,7 +119,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:                  leaderboard[member_id][f"star_{star}"] += 1                  # Record completion datetime for this participant for this day/star -                completion_time = datetime.datetime.fromtimestamp(int(data['get_star_ts'])) +                completion_time = datetime.datetime.fromtimestamp(int(data["get_star_ts"]))                  star_results[(day, star)].append(                      StarResult(member_id=member_id, completion_time=completion_time)                  ) @@ -133,7 +133,7 @@ def _parse_raw_leaderboard_data(raw_leaderboard_data: dict) -> dict:          if day in AdventOfCode.ignored_days:              continue -        sorted_result = sorted(results, key=operator.attrgetter('completion_time')) +        sorted_result = sorted(results, key=operator.attrgetter("completion_time"))          for rank, star_result in enumerate(sorted_result):              leaderboard[star_result.member_id]["score"] += max_score - rank @@ -307,7 +307,7 @@ async def fetch_leaderboard(invalidate_cache: bool = False) -> dict:  def get_summary_embed(leaderboard: dict) -> discord.Embed:      """Get an embed with the current summary stats of the leaderboard.""" -    leaderboard_url = leaderboard['full_leaderboard_url'] +    leaderboard_url = leaderboard["full_leaderboard_url"]      refresh_minutes = AdventOfCode.leaderboard_cache_expiry_seconds // 60      aoc_embed = discord.Embed( diff --git a/bot/exts/christmas/hanukkah_embed.py b/bot/exts/christmas/hanukkah_embed.py index 4f470a34..119f2446 100644 --- a/bot/exts/christmas/hanukkah_embed.py +++ b/bot/exts/christmas/hanukkah_embed.py @@ -5,19 +5,23 @@ from typing import List  from discord import Embed  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours, Month  from bot.utils.decorators import in_month  log = logging.getLogger(__name__) +HEBCAL_URL = ( +    "https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" +    "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on" +) +  class HanukkahEmbed(commands.Cog):      """A cog that returns information about Hanukkah festival.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot -        self.url = ("https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&" -                    "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on")          self.hanukkah_days = []          self.hanukkah_months = []          self.hanukkah_years = [] @@ -25,17 +29,17 @@ class HanukkahEmbed(commands.Cog):      async def get_hanukkah_dates(self) -> List[str]:          """Gets the dates for hanukkah festival."""          hanukkah_dates = [] -        async with self.bot.http_session.get(self.url) as response: +        async with self.bot.http_session.get(HEBCAL_URL) as response:              json_data = await response.json() -        festivals = json_data['items'] +        festivals = json_data["items"]          for festival in festivals: -            if festival['title'].startswith('Chanukah'): -                date = festival['date'] +            if festival["title"].startswith("Chanukah"): +                date = festival["date"]                  hanukkah_dates.append(date)          return hanukkah_dates      @in_month(Month.DECEMBER) -    @commands.command(name='hanukkah', aliases=['chanukah']) +    @commands.command(name="hanukkah", aliases=("chanukah",))      async def hanukkah_festival(self, ctx: commands.Context) -> None:          """Tells you about the Hanukkah Festivaltime of festival, festival day, etc)."""          hanukkah_dates = await self.get_hanukkah_dates() @@ -54,49 +58,46 @@ class HanukkahEmbed(commands.Cog):          day = str(today.day)          month = str(today.month)          year = str(today.year) -        embed = Embed() -        embed.title = 'Hanukkah' -        embed.colour = Colours.blue +        embed = Embed(title="Hanukkah", colour=Colours.blue)          if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years:              if int(day) == hanukkah_start_day:                  now = datetime.datetime.utcnow() -                now = str(now) -                hours = int(now[11:13]) + 4  # using only hours +                hours = now.hour + 4  # using only hours                  hanukkah_start_hour = 18                  if hours < hanukkah_start_hour: -                    embed.description = (f"Hanukkah hasnt started yet, " -                                         f"it will start in about {hanukkah_start_hour-hours} hour/s.") -                    return await ctx.send(embed=embed) +                    embed.description = ( +                        "Hanukkah hasnt started yet, " +                        f"it will start in about {hanukkah_start_hour - hours} hour/s." +                    ) +                    await ctx.send(embed=embed) +                    return                  elif hours > hanukkah_start_hour: -                    embed.description = (f'It is the starting day of Hanukkah ! ' -                                         f'Its been {hours-hanukkah_start_hour} hours hanukkah started !') -                    return await ctx.send(embed=embed) +                    embed.description = ( +                        "It is the starting day of Hanukkah! " +                        f"Its been {hours - hanukkah_start_hour} hours hanukkah started!" +                    ) +                    await ctx.send(embed=embed) +                    return              festival_day = self.hanukkah_days.index(day) -            number_suffixes = ['st', 'nd', 'rd', 'th'] -            suffix = '' -            if int(festival_day) == 1: -                suffix = number_suffixes[0] -            if int(festival_day) == 2: -                suffix = number_suffixes[1] -            if int(festival_day) == 3: -                suffix = number_suffixes[2] -            if int(festival_day) > 3: -                suffix = number_suffixes[3] -            message = '' -            for _ in range(1, festival_day + 1): -                message += ':menorah:' -            embed.description = f'It is the {festival_day}{suffix} day of Hanukkah ! \n {message}' +            number_suffixes = ["st", "nd", "rd", "th"] +            suffix = number_suffixes[festival_day - 1 if festival_day <= 3 else 3] +            message = ":menorah:" * festival_day +            embed.description = f"It is the {festival_day}{suffix} day of Hanukkah!\n{message}"              await ctx.send(embed=embed)          else:              if today < hanukkah_start: -                festival_starting_month = hanukkah_start.strftime('%B') -                embed.description = (f"Hanukkah has not started yet. " -                                     f"Hanukkah will start at sundown on {hanukkah_start_day}th " -                                     f"of {festival_starting_month}.") +                festival_starting_month = hanukkah_start.strftime("%B") +                embed.description = ( +                    f"Hanukkah has not started yet. " +                    f"Hanukkah will start at sundown on {hanukkah_start_day}th " +                    f"of {festival_starting_month}." +                )              else: -                festival_end_month = hanukkah_end.strftime('%B') -                embed.description = (f"Looks like you missed Hanukkah !" -                                     f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}.") +                festival_end_month = hanukkah_end.strftime("%B") +                embed.description = ( +                    f"Looks like you missed Hanukkah!" +                    f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}." +                )              await ctx.send(embed=embed) @@ -108,6 +109,6 @@ class HanukkahEmbed(commands.Cog):              self.hanukkah_years.append(date[0:4]) -def setup(bot: commands.Bot) -> None: -    """Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Hanukkah Embed Cog."""      bot.add_cog(HanukkahEmbed(bot)) diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py index c7a3c014..5ef40704 100644 --- a/bot/exts/easter/april_fools_vids.py +++ b/bot/exts/easter/april_fools_vids.py @@ -1,19 +1,21 @@  import logging  import random -from json import load +from json import loads +from pathlib import Path  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) -with open("bot/resources/easter/april_fools_vids.json", encoding="utf-8") as f: -    ALL_VIDS = load(f) +ALL_VIDS = loads(Path("bot/resources/easter/april_fools_vids.json").read_text("utf-8"))  class AprilFoolVideos(commands.Cog):      """A cog for April Fools' that gets a random April Fools' video from Youtube.""" -    @commands.command(name='fool') +    @commands.command(name="fool")      async def april_fools(self, ctx: commands.Context) -> None:          """Get a random April Fools' video from Youtube."""          video = random.choice(ALL_VIDS) @@ -23,6 +25,6 @@ class AprilFoolVideos(commands.Cog):          await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}") -def setup(bot: commands.Bot) -> None: -    """April Fools' Cog load.""" -    bot.add_cog(AprilFoolVideos(bot)) +def setup(bot: Bot) -> None: +    """Load the April Fools' Cog.""" +    bot.add_cog(AprilFoolVideos()) diff --git a/bot/exts/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py deleted file mode 100644 index 8e8a3500..00000000 --- a/bot/exts/easter/avatar_easterifier.py +++ /dev/null @@ -1,128 +0,0 @@ -import asyncio -import logging -from io import BytesIO -from pathlib import Path -from typing import Tuple, Union - -import discord -from PIL import Image -from PIL.ImageOps import posterize -from discord.ext import commands - -log = logging.getLogger(__name__) - -COLOURS = [ -    (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), -    (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), -    (135, 206, 235), (0, 204, 204), (64, 224, 208) -]  # Pastel colours - Easter-like - - -class AvatarEasterifier(commands.Cog): -    """Put an Easter spin on your avatar or image!""" - -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    @staticmethod -    def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]: -        """ -        Finds the closest easter colour to a given pixel. - -        Returns a merge between the original colour and the closest colour -        """ -        r1, g1, b1 = x - -        def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]: -            """Finds the difference between a pastel colour and the original pixel colour.""" -            r2, g2, b2 = point -            return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) - -        closest_colours = sorted(COLOURS, key=lambda point: distance(point)) -        r2, g2, b2 = closest_colours[0] -        r = (r1 + r2) // 2 -        g = (g1 + g2) // 2 -        b = (b1 + b2) // 2 - -        return (r, g, b) - -    @commands.command(pass_context=True, aliases=["easterify"]) -    async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: -        """ -        This "Easterifies" the user's avatar. - -        Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. -        If colours are not given, a nice little chocolate bunny will sit in the corner. -        Colours are split by spaces, unless you wrap the colour name in double quotes. -        Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. -        """ -        async def send(*args, **kwargs) -> str: -            """ -            This replaces the original ctx.send. - -            When invoking the egg decorating command, the egg itself doesn't print to to the channel. -            Returns the message content so that if any errors occur, the error message can be output. -            """ -            if args: -                return args[0] - -        async with ctx.typing(): - -            # Grabs image of avatar -            image_bytes = await ctx.author.avatar_url_as(size=256).read() - -            old = Image.open(BytesIO(image_bytes)) -            old = old.convert("RGBA") - -            # Grabs alpha channel since posterize can't be used with an RGBA image. -            alpha = old.getchannel("A").getdata() -            old = old.convert("RGB") -            old = posterize(old, 6) - -            data = old.getdata() -            setted_data = set(data) -            new_d = {} - -            for x in setted_data: -                new_d[x] = self.closest(x) -                await asyncio.sleep(0)  # Ensures discord doesn't break in the background. -            new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] - -            im = Image.new("RGBA", old.size) -            im.putdata(new_data) - -            if colours: -                send_message = ctx.send -                ctx.send = send  # Assigns ctx.send to a fake send -                egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) -                if isinstance(egg, str):  # When an error message occurs in eggdecorate. -                    return await send_message(egg) - -                ratio = 64 / egg.height -                egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) -                egg = egg.convert("RGBA") -                im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2))  # Right centre. -                ctx.send = send_message  # Reassigns ctx.send -            else: -                bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) -                im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2))  # Right centre. - -            bufferedio = BytesIO() -            im.save(bufferedio, format="PNG") - -            bufferedio.seek(0) - -            file = discord.File(bufferedio, filename="easterified_avatar.png")  # Creates file to be used in embed -            embed = discord.Embed( -                name="Your Lovely Easterified Avatar", -                description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" -            ) -            embed.set_image(url="attachment://easterified_avatar.png") -            embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - -        await ctx.send(file=file, embed=embed) - - -def setup(bot: commands.Bot) -> None: -    """Avatar Easterifier Cog load.""" -    bot.add_cog(AvatarEasterifier(bot)) diff --git a/bot/exts/easter/bunny_name_generator.py b/bot/exts/easter/bunny_name_generator.py index 3ecf9be9..3e97373f 100644 --- a/bot/exts/easter/bunny_name_generator.py +++ b/bot/exts/easter/bunny_name_generator.py @@ -7,25 +7,26 @@ from typing import List, Union  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) -with Path("bot/resources/easter/bunny_names.json").open("r", encoding="utf8") as f: -    BUNNY_NAMES = json.load(f) +BUNNY_NAMES = json.loads(Path("bot/resources/easter/bunny_names.json").read_text("utf8"))  class BunnyNameGenerator(commands.Cog):      """Generate a random bunny name, or bunnify your Discord username!""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    def find_separators(self, displayname: str) -> Union[List[str], None]: +    @staticmethod +    def find_separators(displayname: str) -> Union[List[str], None]:          """Check if Discord name contains spaces so we can bunnify an individual word in the name.""" -        new_name = re.split(r'[_.\s]', displayname) +        new_name = re.split(r"[_.\s]", displayname)          if displayname not in new_name:              return new_name +        return None -    def find_vowels(self, displayname: str) -> str: +    @staticmethod +    def find_vowels(displayname: str) -> str:          """          Finds vowels in the user's display name. @@ -34,11 +35,11 @@ class BunnyNameGenerator(commands.Cog):          Only the most recently matched pattern will apply the changes.          """          expressions = [ -            (r'a.+y', 'patchy'), -            (r'e.+y', 'ears'), -            (r'i.+y', 'ditsy'), -            (r'o.+y', 'oofy'), -            (r'u.+y', 'uffy'), +            ("a.+y", "patchy"), +            ("e.+y", "ears"), +            ("i.+y", "ditsy"), +            ("o.+y", "oofy"), +            ("u.+y", "uffy"),          ]          for exp, vowel_sub in expressions: @@ -46,9 +47,10 @@ class BunnyNameGenerator(commands.Cog):              if new_name != displayname:                  return new_name -    def append_name(self, displayname: str) -> str: +    @staticmethod +    def append_name(displayname: str) -> str:          """Adds a suffix to the end of the Discord name.""" -        extensions = ['foot', 'ear', 'nose', 'tail'] +        extensions = ["foot", "ear", "nose", "tail"]          suffix = random.choice(extensions)          appended_name = displayname + suffix @@ -62,7 +64,7 @@ class BunnyNameGenerator(commands.Cog):      @commands.command()      async def bunnifyme(self, ctx: commands.Context) -> None:          """Gets your Discord username and bunnifies it.""" -        username = ctx.message.author.display_name +        username = ctx.author.display_name          # If name contains spaces or other separators, get the individual words to randomly bunnify          spaces_in_name = self.find_separators(username) @@ -75,7 +77,7 @@ class BunnyNameGenerator(commands.Cog):          unmatched_name = self.append_name(username)          if spaces_in_name is not None: -            replacements = ['Cotton', 'Fluff', 'Floof' 'Bounce', 'Snuffle', 'Nibble', 'Cuddle', 'Velvetpaw', 'Carrot'] +            replacements = ["Cotton", "Fluff", "Floof" "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"]              word_to_replace = random.choice(spaces_in_name)              substitute = random.choice(replacements)              bunnified_name = username.replace(word_to_replace, substitute) @@ -87,6 +89,6 @@ class BunnyNameGenerator(commands.Cog):          await ctx.send(bunnified_name) -def setup(bot: commands.Bot) -> None: -    """Bunny Name Generator Cog load.""" -    bot.add_cog(BunnyNameGenerator(bot)) +def setup(bot: Bot) -> None: +    """Load the Bunny Name Generator Cog.""" +    bot.add_cog(BunnyNameGenerator()) diff --git a/bot/exts/easter/earth_photos.py b/bot/exts/easter/earth_photos.py index bf658391..f65790af 100644 --- a/bot/exts/easter/earth_photos.py +++ b/bot/exts/easter/earth_photos.py @@ -3,24 +3,27 @@ import logging  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  from bot.constants import Tokens  log = logging.getLogger(__name__) +API_URL = "https://api.unsplash.com/photos/random" +  class EarthPhotos(commands.Cog):      """This cog contains the command for earth photos.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot -    @commands.command(aliases=["earth"]) +    @commands.command(aliases=("earth",))      async def earth_photos(self, ctx: commands.Context) -> None:          """Returns a random photo of earth, sourced from Unsplash."""          async with ctx.typing():              async with self.bot.http_session.get( -                    'https://api.unsplash.com/photos/random', +                    API_URL,                      params={"query": "planet_earth", "client_id": Tokens.unsplash_access_key}              ) as r:                  jsondata = await r.json() @@ -55,7 +58,7 @@ class EarthPhotos(commands.Cog):              await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the Earth Photos cog."""      if not Tokens.unsplash_access_key:          log.warning("No Unsplash access key found. Cog not loading.") diff --git a/bot/exts/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py index a93b3745..88b3be2f 100644 --- a/bot/exts/easter/easter_riddle.py +++ b/bot/exts/easter/easter_riddle.py @@ -1,18 +1,18 @@  import asyncio  import logging  import random -from json import load +from json import loads  from pathlib import Path  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours, NEGATIVE_REPLIES  log = logging.getLogger(__name__) -with Path("bot/resources/easter/easter_riddle.json").open("r", encoding="utf8") as f: -    RIDDLE_QUESTIONS = load(f) +RIDDLE_QUESTIONS = loads(Path("bot/resources/easter/easter_riddle.json").read_text("utf8"))  TIMELIMIT = 10 @@ -20,13 +20,13 @@ TIMELIMIT = 10  class EasterRiddle(commands.Cog):      """This cog contains the command for the Easter quiz!""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot          self.winners = set()          self.correct = ""          self.current_channel = None -    @commands.command(aliases=["riddlemethis", "riddleme"]) +    @commands.command(aliases=("riddlemethis", "riddleme"))      async def riddle(self, ctx: commands.Context) -> None:          """          Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer. @@ -34,7 +34,8 @@ class EasterRiddle(commands.Cog):          The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file.          """          if self.current_channel: -            return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") +            await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!") +            return          # Don't let users start in a DM          if not ctx.guild: @@ -47,7 +48,7 @@ class EasterRiddle(commands.Cog):              )              return -        self.current_channel = ctx.message.channel +        self.current_channel = ctx.channel          random_question = random.choice(RIDDLE_QUESTIONS)          question = random_question["question"] @@ -106,6 +107,6 @@ class EasterRiddle(commands.Cog):              self.winners.add(message.author.mention) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Easter Riddle Cog load."""      bot.add_cog(EasterRiddle(bot)) diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py index b18e6636..fd7620d4 100644 --- a/bot/exts/easter/egg_decorating.py +++ b/bot/exts/easter/egg_decorating.py @@ -10,13 +10,14 @@ import discord  from PIL import Image  from discord.ext import commands +from bot.bot import Bot +from bot.utils import helpers +  log = logging.getLogger(__name__) -with open(Path("bot/resources/evergreen/html_colours.json"), encoding="utf8") as f: -    HTML_COLOURS = json.load(f) +HTML_COLOURS = json.loads(Path("bot/resources/evergreen/html_colours.json").read_text("utf8")) -with open(Path("bot/resources/evergreen/xkcd_colours.json"), encoding="utf8") as f: -    XKCD_COLOURS = json.load(f) +XKCD_COLOURS = json.loads(Path("bot/resources/evergreen/xkcd_colours.json").read_text("utf8"))  COLOURS = [      (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255), @@ -31,9 +32,6 @@ IRREPLACEABLE = [  class EggDecorating(commands.Cog):      """Decorate some easter eggs!""" -    def __init__(self, bot: commands.Bot) -> None: -        self.bot = bot -      @staticmethod      def replace_invalid(colour: str) -> Union[int, None]:          """Attempts to match with HTML or XKCD colour names, returning the int value.""" @@ -43,10 +41,10 @@ class EggDecorating(commands.Cog):              return int(XKCD_COLOURS[colour], 16)          return None -    @commands.command(aliases=["decorateegg"]) +    @commands.command(aliases=("decorateegg",))      async def eggdecorate(          self, ctx: commands.Context, *colours: Union[discord.Colour, str] -    ) -> Union[Image.Image, discord.Message]: +    ) -> Union[Image.Image, None]:          """          Picks a random egg design and decorates it using the given colours. @@ -54,7 +52,8 @@ class EggDecorating(commands.Cog):          Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.          """          if len(colours) < 2: -            return await ctx.send("You must include at least 2 colours!") +            await ctx.send("You must include at least 2 colours!") +            return          invalid = []          colours = list(colours) @@ -65,12 +64,14 @@ class EggDecorating(commands.Cog):              if value:                  colours[idx] = discord.Colour(value)              else: -                invalid.append(colour) +                invalid.append(helpers.suppress_links(colour))          if len(invalid) > 1: -            return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") +            await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") +            return          elif len(invalid) == 1: -            return await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") +            await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!") +            return          async with ctx.typing():              # Expand list to 8 colours @@ -113,6 +114,6 @@ class EggDecorating(commands.Cog):          return new_im -def setup(bot: commands.bot) -> None: -    """Egg decorating Cog load.""" -    bot.add_cog(EggDecorating(bot)) +def setup(bot: Bot) -> None: +    """Load the Egg decorating Cog.""" +    bot.add_cog(EggDecorating()) diff --git a/bot/exts/easter/egg_facts.py b/bot/exts/easter/egg_facts.py index 761e9059..486e735f 100644 --- a/bot/exts/easter/egg_facts.py +++ b/bot/exts/easter/egg_facts.py @@ -1,6 +1,6 @@  import logging  import random -from json import load +from json import loads  from pathlib import Path  import discord @@ -12,6 +12,8 @@ from bot.utils.decorators import seasonal_task  log = logging.getLogger(__name__) +EGG_FACTS = loads(Path("bot/resources/easter/easter_egg_facts.json").read_text("utf8")) +  class EasterFacts(commands.Cog):      """ @@ -22,17 +24,8 @@ class EasterFacts(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.facts = self.load_json() -          self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily()) -    @staticmethod -    def load_json() -> dict: -        """Load a list of easter egg facts from the resource JSON file.""" -        p = Path("bot/resources/easter/easter_egg_facts.json") -        with p.open(encoding="utf8") as f: -            return load(f) -      @seasonal_task(Month.APRIL)      async def send_egg_fact_daily(self) -> None:          """A background task that sends an easter egg fact in the event channel everyday.""" @@ -41,21 +34,22 @@ class EasterFacts(commands.Cog):          channel = self.bot.get_channel(Channels.community_bot_commands)          await channel.send(embed=self.make_embed()) -    @commands.command(name='eggfact', aliases=['fact']) +    @commands.command(name="eggfact", aliases=("fact",))      async def easter_facts(self, ctx: commands.Context) -> None:          """Get easter egg facts."""          embed = self.make_embed()          await ctx.send(embed=embed) -    def make_embed(self) -> discord.Embed: +    @staticmethod +    def make_embed() -> discord.Embed:          """Makes a nice embed for the message to be sent."""          return discord.Embed(              colour=Colours.soft_red,              title="Easter Egg Fact", -            description=random.choice(self.facts) +            description=random.choice(EGG_FACTS)          )  def setup(bot: Bot) -> None: -    """Easter Egg facts cog load.""" +    """Load the Easter Egg facts Cog."""      bot.add_cog(EasterFacts(bot)) diff --git a/bot/exts/easter/egghead_quiz.py b/bot/exts/easter/egghead_quiz.py index 0498d9db..7c4960cd 100644 --- a/bot/exts/easter/egghead_quiz.py +++ b/bot/exts/easter/egghead_quiz.py @@ -1,28 +1,28 @@  import asyncio  import logging  import random -from json import load +from json import loads  from pathlib import Path  from typing import Union  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -with open(Path("bot/resources/easter/egghead_questions.json"), "r", encoding="utf8") as f: -    EGGHEAD_QUESTIONS = load(f) +EGGHEAD_QUESTIONS = loads(Path("bot/resources/easter/egghead_questions.json").read_text("utf8"))  EMOJIS = [ -    '\U0001f1e6', '\U0001f1e7', '\U0001f1e8', '\U0001f1e9', '\U0001f1ea', -    '\U0001f1eb', '\U0001f1ec', '\U0001f1ed', '\U0001f1ee', '\U0001f1ef', -    '\U0001f1f0', '\U0001f1f1', '\U0001f1f2', '\U0001f1f3', '\U0001f1f4', -    '\U0001f1f5', '\U0001f1f6', '\U0001f1f7', '\U0001f1f8', '\U0001f1f9', -    '\U0001f1fa', '\U0001f1fb', '\U0001f1fc', '\U0001f1fd', '\U0001f1fe', -    '\U0001f1ff' +    "\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9", "\U0001f1ea", +    "\U0001f1eb", "\U0001f1ec", "\U0001f1ed", "\U0001f1ee", "\U0001f1ef", +    "\U0001f1f0", "\U0001f1f1", "\U0001f1f2", "\U0001f1f3", "\U0001f1f4", +    "\U0001f1f5", "\U0001f1f6", "\U0001f1f7", "\U0001f1f8", "\U0001f1f9", +    "\U0001f1fa", "\U0001f1fb", "\U0001f1fc", "\U0001f1fd", "\U0001f1fe", +    "\U0001f1ff"  ]  # Regional Indicators A-Z (used for voting)  TIMELIMIT = 30 @@ -31,11 +31,10 @@ TIMELIMIT = 30  class EggheadQuiz(commands.Cog):      """This cog contains the command for the Easter quiz!""" -    def __init__(self, bot: commands.Bot) -> None: -        self.bot = bot +    def __init__(self) -> None:          self.quiz_messages = {} -    @commands.command(aliases=["eggheadquiz", "easterquiz"]) +    @commands.command(aliases=("eggheadquiz", "easterquiz"))      async def eggquiz(self, ctx: commands.Context) -> None:          """          Gives a random quiz question, waits 30 seconds and then outputs the answer. @@ -64,7 +63,7 @@ class EggheadQuiz(commands.Cog):          del self.quiz_messages[msg.id] -        msg = await ctx.channel.fetch_message(msg.id)  # Refreshes message +        msg = await ctx.fetch_message(msg.id)  # Refreshes message          total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis)  # - bot's reactions @@ -114,6 +113,6 @@ class EggheadQuiz(commands.Cog):              return await reaction.message.remove_reaction(reaction, user) -def setup(bot: commands.Bot) -> None: -    """Egghead Quiz Cog load.""" -    bot.add_cog(EggheadQuiz(bot)) +def setup(bot: Bot) -> None: +    """Load the Egghead Quiz Cog.""" +    bot.add_cog(EggheadQuiz()) diff --git a/bot/exts/easter/save_the_planet.py b/bot/exts/easter/save_the_planet.py index 8f644259..1bd515f2 100644 --- a/bot/exts/easter/save_the_planet.py +++ b/bot/exts/easter/save_the_planet.py @@ -4,26 +4,22 @@ from pathlib import Path  from discord import Embed  from discord.ext import commands +from bot.bot import Bot  from bot.utils.randomization import RandomCycle - -with Path("bot/resources/easter/save_the_planet.json").open('r', encoding='utf8') as f: -    EMBED_DATA = RandomCycle(json.load(f)) +EMBED_DATA = RandomCycle(json.loads(Path("bot/resources/easter/save_the_planet.json").read_text("utf8")))  class SaveThePlanet(commands.Cog):      """A cog that teaches users how they can help our planet.""" -    def __init__(self, bot: commands.Bot) -> None: -        self.bot = bot - -    @commands.command(aliases=('savetheearth', 'saveplanet', 'saveearth')) +    @commands.command(aliases=("savetheearth", "saveplanet", "saveearth"))      async def savetheplanet(self, ctx: commands.Context) -> None:          """Responds with a random tip on how to be eco-friendly and help our planet."""          return_embed = Embed.from_dict(next(EMBED_DATA))          await ctx.send(embed=return_embed) -def setup(bot: commands.Bot) -> None: -    """Save the Planet Cog load.""" -    bot.add_cog(SaveThePlanet(bot)) +def setup(bot: Bot) -> None: +    """Load the Save the Planet Cog.""" +    bot.add_cog(SaveThePlanet()) diff --git a/bot/exts/easter/traditions.py b/bot/exts/easter/traditions.py index 85b4adfb..93404f3e 100644 --- a/bot/exts/easter/traditions.py +++ b/bot/exts/easter/traditions.py @@ -5,19 +5,17 @@ from pathlib import Path  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) -with open(Path("bot/resources/easter/traditions.json"), "r", encoding="utf8") as f: -    traditions = json.load(f) +traditions = json.loads(Path("bot/resources/easter/traditions.json").read_text("utf8"))  class Traditions(commands.Cog):      """A cog which allows users to get a random easter tradition or custom from a random country.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    @commands.command(aliases=('eastercustoms',)) +    @commands.command(aliases=("eastercustoms",))      async def easter_tradition(self, ctx: commands.Context) -> None:          """Responds with a random tradition or custom."""          random_country = random.choice(list(traditions)) @@ -25,6 +23,6 @@ class Traditions(commands.Cog):          await ctx.send(f"{random_country}:\n{traditions[random_country]}") -def setup(bot: commands.Bot) -> None: -    """Traditions Cog load.""" -    bot.add_cog(Traditions(bot)) +def setup(bot: Bot) -> None: +    """Load the Traditions Cog.""" +    bot.add_cog(Traditions()) diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py deleted file mode 100644 index 7eb4d313..00000000 --- a/bot/exts/evergreen/8bitify.py +++ /dev/null @@ -1,55 +0,0 @@ -from io import BytesIO - -import discord -from PIL import Image -from discord.ext import commands - - -class EightBitify(commands.Cog): -    """Make your avatar 8bit!""" - -    def __init__(self, bot: commands.Bot) -> None: -        self.bot = bot - -    @staticmethod -    def pixelate(image: Image) -> Image: -        """Takes an image and pixelates it.""" -        return image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) - -    @staticmethod -    def quantize(image: Image) -> Image: -        """Reduces colour palette to 256 colours.""" -        return image.quantize() - -    @commands.command(name="8bitify") -    async def eightbit_command(self, ctx: commands.Context) -> None: -        """Pixelates your avatar and changes the palette to an 8bit one.""" -        async with ctx.typing(): -            author = await self.bot.fetch_user(ctx.author.id) -            image_bytes = await author.avatar_url.read() -            avatar = Image.open(BytesIO(image_bytes)) -            avatar = avatar.convert("RGBA").resize((1024, 1024)) - -            eightbit = self.pixelate(avatar) -            eightbit = self.quantize(eightbit) - -            bufferedio = BytesIO() -            eightbit.save(bufferedio, format="PNG") -            bufferedio.seek(0) - -            file = discord.File(bufferedio, filename="8bitavatar.png") - -            embed = discord.Embed( -                title="Your 8-bit avatar", -                description='Here is your avatar. I think it looks all cool and "retro"' -            ) - -            embed.set_image(url="attachment://8bitavatar.png") -            embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - -        await ctx.send(file=file, embed=embed) - - -def setup(bot: commands.Bot) -> None: -    """Cog load.""" -    bot.add_cog(EightBitify(bot)) diff --git a/bot/exts/evergreen/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/bot/exts/evergreen/avatar_modification/__init__.py diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py new file mode 100644 index 00000000..b53b26f3 --- /dev/null +++ b/bot/exts/evergreen/avatar_modification/_effects.py @@ -0,0 +1,287 @@ +import math +import random +import typing as t +from io import BytesIO +from pathlib import Path + +import discord +from PIL import Image, ImageDraw, ImageOps + +from bot.constants import Colours + + +class PfpEffects: +    """ +    Implements various image modifying effects, for the PfpModify cog. + +    All of these functions are slow, and blocking, so they should be ran in executors. +    """ + +    @staticmethod +    def apply_effect(image_bytes: bytes, effect: t.Callable, filename: str, *args) -> discord.File: +        """Applies the given effect to the image passed to it.""" +        im = Image.open(BytesIO(image_bytes)) +        im = im.convert("RGBA") +        im = effect(im, *args) + +        bufferedio = BytesIO() +        im.save(bufferedio, format="PNG") +        bufferedio.seek(0) + +        return discord.File(bufferedio, filename=filename) + +    @staticmethod +    def closest(x: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]: +        """ +        Finds the closest "easter" colour to a given pixel. + +        Returns a merge between the original colour and the closest colour. +        """ +        r1, g1, b1 = x + +        def distance(point: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]: +            """Finds the difference between a pastel colour and the original pixel colour.""" +            r2, g2, b2 = point +            return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 + +        closest_colours = sorted(Colours.easter_like_colours, key=distance) +        r2, g2, b2 = closest_colours[0] +        r = (r1 + r2) // 2 +        g = (g1 + g2) // 2 +        b = (b1 + b2) // 2 + +        return r, g, b + +    @staticmethod +    def crop_avatar_circle(avatar: Image.Image) -> Image.Image: +        """This crops the avatar given into a circle.""" +        mask = Image.new("L", avatar.size, 0) +        draw = ImageDraw.Draw(mask) +        draw.ellipse((0, 0) + avatar.size, fill=255) +        avatar.putalpha(mask) +        return avatar + +    @staticmethod +    def crop_ring(ring: Image.Image, px: int) -> Image.Image: +        """This crops the given ring into a circle.""" +        mask = Image.new("L", ring.size, 0) +        draw = ImageDraw.Draw(mask) +        draw.ellipse((0, 0) + ring.size, fill=255) +        draw.ellipse((px, px, 1024-px, 1024-px), fill=0) +        ring.putalpha(mask) +        return ring + +    @staticmethod +    def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image: +        """Applies the given pride effect to the given image.""" +        image = image.resize((1024, 1024)) +        image = PfpEffects.crop_avatar_circle(image) + +        ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) +        ring = ring.convert("RGBA") +        ring = PfpEffects.crop_ring(ring, pixels) + +        image.alpha_composite(ring, (0, 0)) +        return image + +    @staticmethod +    def eight_bitify_effect(image: Image.Image) -> Image.Image: +        """ +        Applies the 8bit effect to the given image. + +        This is done by reducing the image to 32x32 and then back up to 1024x1024. +        We then quantize the image before returning too. +        """ +        image = image.resize((32, 32), resample=Image.NEAREST) +        image = image.resize((1024, 1024), resample=Image.NEAREST) +        return image.quantize() + +    @staticmethod +    def easterify_effect(image: Image.Image, overlay_image: t.Optional[Image.Image] = None) -> Image.Image: +        """ +        Applies the easter effect to the given image. + +        This is done by getting the closest "easter" colour to each pixel and changing the colour +        to the half-way RGB value. + +        We also then add an overlay image on top in middle right, a chocolate bunny by default. +        """ +        if overlay_image: +            ratio = 64 / overlay_image.height +            overlay_image = overlay_image.resize(( +                round(overlay_image.width * ratio), +                round(overlay_image.height * ratio) +            )) +            overlay_image = overlay_image.convert("RGBA") +        else: +            overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) + +        alpha = image.getchannel("A").getdata() +        image = image.convert("RGB") +        image = ImageOps.posterize(image, 6) + +        data = image.getdata() +        data_set = set(data) +        easterified_data_set = {} + +        for x in data_set: +            easterified_data_set[x] = PfpEffects.closest(x) +        new_pixel_data = [ +            (*easterified_data_set[x], alpha[i]) +            if x in easterified_data_set else x +            for i, x in enumerate(data) +        ] + +        im = Image.new("RGBA", image.size) +        im.putdata(new_pixel_data) +        im.alpha_composite( +            overlay_image, +            (im.width - overlay_image.width, (im.height - overlay_image.height) // 2) +        ) +        return im + +    @staticmethod +    def split_image(img: Image.Image, squares: int) -> list: +        """ +        Split an image into a selection of squares, specified by the squares argument. + +        Explanation: + +        1. It gets the width and the height of the Image passed to the function. + +        2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say +        25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed +        to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it +        as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows +        and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the +        program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed. + +        3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares, +        the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares) +        passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note: +        x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width +        in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width. +        x_frac and y_frac are width and height of a single square (split piece). + +        4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial +        square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding +        value to right and bottom, it's creating the initial square (split piece). + +        5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is +        True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between +        them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list +        where it stores them. The program keeps repeating this process till all 25 squares get added to the list. + +        6. It returns new_imgs, a list of squares (split pieces). +        """ +        width, heigth = img.size + +        xy = math.sqrt(squares) + +        x_frac = width // xy +        y_frac = heigth // xy + +        left, top, right, bottom, = 0, 0, x_frac, y_frac + +        new_imgs = [] + +        for index in range(squares): +            new_img = img.crop((left, top, right, bottom)) +            new_imgs.append(new_img) + +            if (index + 1) % xy == 0: +                top += y_frac +                bottom += y_frac +                left = 0 +                right = x_frac +            else: +                left += x_frac +                right += x_frac + +        return new_imgs + +    @staticmethod +    def join_images(images: t.List[Image.Image]) -> Image.Image: +        """ +        Stitches all the image squares into a new image. + +        Explanation: + +        1. Shuffles the passed images to randomize the pieces. + +        2. The program gets a single square (split piece) out of the list and defines single_width as the square's width +        and single_height as the square's height. + +        3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier. +        Program then proceeds to calculate total height and width of the new image that it's creating using the same +        multiplier. + +        4. The program then defines new_image as the image that it's creating, using the previously obtained total_width +        and total_height. + +        5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position +        squares (split pieces) onto the new_image canvas. + +        6. Similar to how in the split_image function, the program gets the root of number of images in the list. +        In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the +        list that it got the square of here. + +        7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly +        position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece) +        onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get +        pasted in the same spot and the positioning would move accordingly. It makes sure to increase the +        width_multiplier before the check, which checks if the end of a row has been reached, - +        (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of +        the row). If the check returns True, the height gets increased by a single square's (split piece) height to +        lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will +        then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were +        positioned accordingly. + +        8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the +        original image - user's PFP. +        """ +        random.shuffle(images) +        single_img = images[0] + +        single_wdith = single_img.size[0] +        single_height = single_img.size[1] + +        multiplier = int(math.sqrt(len(images))) + +        total_width = multiplier * single_wdith +        total_height = multiplier * single_height + +        new_image = Image.new("RGBA", (total_width, total_height), (250, 250, 250)) + +        width_multiplier = 0 +        height = 0 + +        squares = math.sqrt(len(images)) + +        for index, image in enumerate(images): +            width = single_wdith * width_multiplier + +            new_image.paste(image, (width, height)) + +            width_multiplier += 1 + +            if (index + 1) % squares == 0: +                width_multiplier = 0 +                height += single_height + +        return new_image + +    @staticmethod +    def mosaic_effect(img_bytes: bytes, squares: int, file_name: str) -> discord.File: +        """Separate function run from an executor which turns an image into a mosaic.""" +        avatar = Image.open(BytesIO(img_bytes)) +        avatar = avatar.convert("RGBA").resize((1024, 1024)) + +        img_squares = PfpEffects.split_image(avatar, squares) +        new_img = PfpEffects.join_images(img_squares) + +        bufferedio = BytesIO() +        new_img.save(bufferedio, format="PNG") +        bufferedio.seek(0) + +        return discord.File(bufferedio, filename=file_name) diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py new file mode 100644 index 00000000..17f34ed4 --- /dev/null +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -0,0 +1,370 @@ +import asyncio +import json +import logging +import math +import string +import typing as t +import unicodedata +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import discord +from aiohttp import client_exceptions +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Colours, Emojis +from bot.exts.evergreen.avatar_modification._effects import PfpEffects +from bot.utils.extensions import invoke_help_command +from bot.utils.halloween import spookifications + +log = logging.getLogger(__name__) + +_EXECUTOR = ThreadPoolExecutor(10) + +FILENAME_STRING = "{effect}_{author}.png" + +MAX_SQUARES = 10_000 + +T = t.TypeVar("T") + +GENDER_OPTIONS = json.loads(Path("bot/resources/pride/gender_options.json").read_text("utf8")) + + +async def in_executor(func: t.Callable[..., T], *args) -> T: +    """ +    Runs the given synchronous function `func` in an executor. + +    This is useful for running slow, blocking code within async +    functions, so that they don't block the bot. +    """ +    log.trace(f"Running {func.__name__} in an executor.") +    loop = asyncio.get_event_loop() +    return await loop.run_in_executor(_EXECUTOR, func, *args) + + +def file_safe_name(effect: str, display_name: str) -> str: +    """Returns a file safe filename based on the given effect and display name.""" +    valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}" + +    file_name = FILENAME_STRING.format(effect=effect, author=display_name) + +    # Replace spaces +    file_name = file_name.replace(" ", "_") + +    # Normalize unicode characters +    cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode() + +    # Remove invalid filename characters +    cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars) +    return cleaned_filename + + +class AvatarModify(commands.Cog): +    """Various commands for users to apply affects to their own avatars.""" + +    def __init__(self, bot: Bot) -> None: +        self.bot = bot + +    async def _fetch_user(self, user_id: int) -> t.Optional[discord.User]: +        """ +        Fetches a user and handles errors. + +        This helper function is required as the member cache doesn't always have the most up to date +        profile picture. This can lead to errors if the image is deleted from the Discord CDN. +        fetch_member can't be used due to the avatar url being part of the user object, and +        some weird caching that D.py does +        """ +        try: +            user = await self.bot.fetch_user(user_id) +        except discord.errors.NotFound: +            log.debug(f"User {user_id} could not be found.") +            return None +        except discord.HTTPException: +            log.exception(f"Exception while trying to retrieve user {user_id} from Discord.") +            return None + +        return user + +    @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod")) +    async def avatar_modify(self, ctx: commands.Context) -> None: +        """Groups all of the pfp modifying commands to allow a single concurrency limit.""" +        if not ctx.invoked_subcommand: +            await invoke_help_command(ctx) + +    @avatar_modify.command(name="8bitify", root_aliases=("8bitify",)) +    async def eightbit_command(self, ctx: commands.Context) -> None: +        """Pixelates your avatar and changes the palette to an 8bit one.""" +        async with ctx.typing(): +            user = await self._fetch_user(ctx.author.id) +            if not user: +                await ctx.send(f"{Emojis.cross_mark} Could not get user info.") +                return + +            image_bytes = await user.avatar_url_as(size=1024).read() +            file_name = file_safe_name("eightbit_avatar", ctx.author.display_name) + +            file = await in_executor( +                PfpEffects.apply_effect, +                image_bytes, +                PfpEffects.eight_bitify_effect, +                file_name +            ) + +            embed = discord.Embed( +                title="Your 8-bit avatar", +                description="Here is your avatar. I think it looks all cool and 'retro'." +            ) + +            embed.set_image(url=f"attachment://{file_name}") +            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url) + +        await ctx.send(embed=embed, file=file) + +    @avatar_modify.command(aliases=("easterify",), root_aliases=("easterify", "avatareasterify")) +    async def avatareasterify(self, ctx: commands.Context, *colours: t.Union[discord.Colour, str]) -> None: +        """ +        This "Easterifies" the user's avatar. + +        Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. +        If colours are not given, a nice little chocolate bunny will sit in the corner. +        Colours are split by spaces, unless you wrap the colour name in double quotes. +        Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. +        """ +        async def send(*args, **kwargs) -> str: +            """ +            This replaces the original ctx.send. + +            When invoking the egg decorating command, the egg itself doesn't print to to the channel. +            Returns the message content so that if any errors occur, the error message can be output. +            """ +            if args: +                return args[0] + +        async with ctx.typing(): +            user = await self._fetch_user(ctx.author.id) +            if not user: +                await ctx.send(f"{Emojis.cross_mark} Could not get user info.") +                return + +            egg = None +            if colours: +                send_message = ctx.send +                ctx.send = send  # Assigns ctx.send to a fake send +                egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) +                if isinstance(egg, str):  # When an error message occurs in eggdecorate. +                    await send_message(egg) +                    return +                ctx.send = send_message  # Reassigns ctx.send + +            image_bytes = await user.avatar_url_as(size=256).read() +            file_name = file_safe_name("easterified_avatar", ctx.author.display_name) + +            file = await in_executor( +                PfpEffects.apply_effect, +                image_bytes, +                PfpEffects.easterify_effect, +                file_name, +                egg +            ) + +            embed = discord.Embed( +                name="Your Lovely Easterified Avatar!", +                description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" +            ) +            embed.set_image(url=f"attachment://{file_name}") +            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.avatar_url) + +        await ctx.send(file=file, embed=embed) + +    @staticmethod +    async def send_pride_image( +        ctx: commands.Context, +        image_bytes: bytes, +        pixels: int, +        flag: str, +        option: str +    ) -> None: +        """Gets and sends the image in an embed. Used by the pride commands.""" +        async with ctx.typing(): +            file_name = file_safe_name("pride_avatar", ctx.author.display_name) + +            file = await in_executor( +                PfpEffects.apply_effect, +                image_bytes, +                PfpEffects.pridify_effect, +                file_name, +                pixels, +                flag +            ) + +            embed = discord.Embed( +                name="Your Lovely Pride Avatar!", +                description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" +            ) +            embed.set_image(url=f"attachment://{file_name}") +            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) +            await ctx.send(file=file, embed=embed) + +    @avatar_modify.group( +        aliases=("avatarpride", "pridepfp", "prideprofile"), +        root_aliases=("prideavatar", "avatarpride", "pridepfp", "prideprofile"), +        invoke_without_command=True +    ) +    async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None: +        """ +        This surrounds an avatar with a border of a specified LGBT flag. + +        This defaults to the LGBT rainbow flag if none is given. +        The amount of pixels can be given which determines the thickness of the flag border. +        This has a maximum of 512px and defaults to a 64px border. +        The full image is 1024x1024. +        """ +        option = option.lower() +        pixels = max(0, min(512, pixels)) +        flag = GENDER_OPTIONS.get(option) +        if flag is None: +            await ctx.send("I don't have that flag!") +            return + +        async with ctx.typing(): +            user = await self._fetch_user(ctx.author.id) +            if not user: +                await ctx.send(f"{Emojis.cross_mark} Could not get user info.") +                return +            image_bytes = await user.avatar_url_as(size=1024).read() +            await self.send_pride_image(ctx, image_bytes, pixels, flag, option) + +    @prideavatar.command() +    async def image(self, ctx: commands.Context, url: str, option: str = "lgbt", pixels: int = 64) -> None: +        """ +        This surrounds the image specified by the URL with a border of a specified LGBT flag. + +        This defaults to the LGBT rainbow flag if none is given. +        The amount of pixels can be given which determines the thickness of the flag border. +        This has a maximum of 512px and defaults to a 64px border. +        The full image is 1024x1024. +        """ +        option = option.lower() +        pixels = max(0, min(512, pixels)) +        flag = GENDER_OPTIONS.get(option) +        if flag is None: +            await ctx.send("I don't have that flag!") +            return + +        async with ctx.typing(): +            try: +                async with self.bot.http_session.get(url) as response: +                    if response.status != 200: +                        await ctx.send("Bad response from provided URL!") +                        return +                    image_bytes = await response.read() +            except client_exceptions.ClientConnectorError: +                raise commands.BadArgument("Cannot connect to provided URL!") +            except client_exceptions.InvalidURL: +                raise commands.BadArgument("Invalid URL!") + +            await self.send_pride_image(ctx, image_bytes, pixels, flag, option) + +    @prideavatar.command() +    async def flags(self, ctx: commands.Context) -> None: +        """This lists the flags that can be used with the prideavatar command.""" +        choices = sorted(set(GENDER_OPTIONS.values())) +        options = "• " + "\n• ".join(choices) +        embed = discord.Embed( +            title="I have the following flags:", +            description=options, +            colour=Colours.soft_red +        ) +        await ctx.send(embed=embed) + +    @avatar_modify.command( +        aliases=("savatar", "spookify"), +        root_aliases=("spookyavatar", "spookify", "savatar"), +        brief="Spookify an user's avatar." +    ) +    async def spookyavatar(self, ctx: commands.Context, member: discord.Member = None) -> None: +        """This "spookifies" the given user's avatar, with a random *spooky* effect.""" +        if member is None: +            member = ctx.author + +        user = await self._fetch_user(member.id) +        if not user: +            await ctx.send(f"{Emojis.cross_mark} Could not get user info.") +            return + +        async with ctx.typing(): +            image_bytes = await user.avatar_url_as(size=1024).read() + +            file_name = file_safe_name("spooky_avatar", member.display_name) + +            file = await in_executor( +                PfpEffects.apply_effect, +                image_bytes, +                spookifications.get_random_effect, +                file_name +            ) + +            embed = discord.Embed( +                title="Is this you or am I just really paranoid?", +                colour=Colours.soft_red +            ) +            embed.set_author(name=member.name, icon_url=member.avatar_url) +            embed.set_image(url=f"attachment://{file_name}") +            embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) + +            await ctx.send(file=file, embed=embed) + +    @avatar_modify.command(name="mosaic", root_aliases=("mosaic",)) +    async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None: +        """Splits your avatar into x squares, randomizes them and stitches them back into a new image!""" +        async with ctx.typing(): +            user = await self._fetch_user(ctx.author.id) +            if not user: +                await ctx.send(f"{Emojis.cross_mark} Could not get user info.") +                return + +            if not 1 <= squares <= MAX_SQUARES: +                raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.") + +            sqrt = math.sqrt(squares) + +            if not sqrt.is_integer(): +                squares = math.ceil(sqrt) ** 2  # Get the next perfect square + +            file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) + +            img_bytes = await user.avatar_url_as(size=1024).read() + +            file = await in_executor( +                PfpEffects.mosaic_effect, +                img_bytes, +                squares, +                file_name +            ) + +            if squares == 1: +                title = "Hooh... that was a lot of work" +                description = "I present to you... Yourself!" +            elif squares == MAX_SQUARES: +                title = "Testing the limits I see..." +                description = "What a masterpiece. :star:" +            else: +                title = "Your mosaic avatar" +                description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares." + +            embed = discord.Embed( +                title=title, +                description=description, +                colour=Colours.blue +            ) + +            embed.set_image(url=f"attachment://{file_name}") +            embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.avatar_url) + +            await ctx.send(file=file, embed=embed) + + +def setup(bot: Bot) -> None: +    """Load the AvatarModify cog.""" +    bot.add_cog(AvatarModify(bot)) diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py index 1681434f..c2f2079c 100644 --- a/bot/exts/evergreen/battleship.py +++ b/bot/exts/evergreen/battleship.py @@ -9,6 +9,7 @@ from functools import partial  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) @@ -30,8 +31,8 @@ EmojiSet = typing.Dict[typing.Tuple[bool, bool], str]  class Player:      """Each player in the game - their messages for the boards and their current grid.""" -    user: discord.Member -    board: discord.Message +    user: typing.Optional[discord.Member] +    board: typing.Optional[discord.Message]      opponent_board: discord.Message      grid: Grid @@ -95,7 +96,7 @@ class Game:      def __init__(          self, -        bot: commands.Bot, +        bot: Bot,          channel: discord.TextChannel,          player1: discord.Member,          player2: discord.Member @@ -237,7 +238,7 @@ class Game:          square = None          turn_message = await self.turn.user.send(              "It's your turn! Type the square you want to fire at. Format it like this: A1\n" -            "Type `surrender` to give up" +            "Type `surrender` to give up."          )          await self.next.user.send("Their turn", delete_after=3.0)          while True: @@ -321,7 +322,7 @@ class Game:  class Battleship(commands.Cog):      """Play the classic game Battleship!""" -    def __init__(self, bot: commands.Bot) -> None: +    def __init__(self, bot: Bot) -> None:          self.bot = bot          self.games: typing.List[Game] = []          self.waiting: typing.List[discord.Member] = [] @@ -378,10 +379,12 @@ class Battleship(commands.Cog):          Make sure you have your DMs open so that the bot can message you.          """          if self.already_playing(ctx.author): -            return await ctx.send("You're already playing a game!") +            await ctx.send("You're already playing a game!") +            return          if ctx.author in self.waiting: -            return await ctx.send("You've already sent out a request for a player 2") +            await ctx.send("You've already sent out a request for a player 2.") +            return          announcement = await ctx.send(              "**Battleship**: A new game is about to start!\n" @@ -401,20 +404,22 @@ class Battleship(commands.Cog):          except asyncio.TimeoutError:              self.waiting.remove(ctx.author)              await announcement.delete() -            return await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") +            await ctx.send(f"{ctx.author.mention} Seems like there's no one here to play...") +            return          if str(reaction.emoji) == CROSS_EMOJI:              self.waiting.remove(ctx.author)              await announcement.delete() -            return await ctx.send(f"{ctx.author.mention} Game cancelled.") +            await ctx.send(f"{ctx.author.mention} Game cancelled.") +            return          await announcement.delete()          self.waiting.remove(ctx.author)          if self.already_playing(ctx.author):              return +        game = Game(self.bot, ctx.channel, ctx.author, user) +        self.games.append(game)          try: -            game = Game(self.bot, ctx.channel, ctx.author, user) -            self.games.append(game)              await game.start_game()              self.games.remove(game)          except discord.Forbidden: @@ -425,11 +430,11 @@ class Battleship(commands.Cog):              self.games.remove(game)          except Exception:              # End the game in the event of an unforseen error so the players aren't stuck in a game -            await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed") +            await ctx.send(f"{ctx.author.mention} {user.mention} An error occurred. Game failed.")              self.games.remove(game)              raise -    @battleship.command(name="ships", aliases=["boats"]) +    @battleship.command(name="ships", aliases=("boats",))      async def battleship_ships(self, ctx: commands.Context) -> None:          """Lists the ships that are found on the battleship grid."""          embed = discord.Embed(colour=Colours.blue) @@ -438,6 +443,6 @@ class Battleship(commands.Cog):          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Battleship Cog."""      bot.add_cog(Battleship(bot)) diff --git a/bot/exts/evergreen/bookmark.py b/bot/exts/evergreen/bookmark.py index 5fa05d2e..29915627 100644 --- a/bot/exts/evergreen/bookmark.py +++ b/bot/exts/evergreen/bookmark.py @@ -1,21 +1,89 @@ +import asyncio  import logging  import random  import discord  from discord.ext import commands -from bot.constants import Colours, ERROR_REPLIES, Emojis, Icons +from bot.bot import Bot +from bot.constants import Colours, ERROR_REPLIES, Icons  from bot.utils.converters import WrappedMessageConverter  log = logging.getLogger(__name__) +# Number of seconds to wait for other users to bookmark the same message +TIMEOUT = 120 +BOOKMARK_EMOJI = "📌" +  class Bookmark(commands.Cog):      """Creates personal bookmarks by relaying a message link to the user's DMs.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot +    @staticmethod +    def build_bookmark_dm(target_message: discord.Message, title: str) -> discord.Embed: +        """Build the embed to DM the bookmark requester.""" +        embed = discord.Embed( +            title=title, +            description=target_message.content, +            colour=Colours.soft_green +        ) +        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=Icons.bookmark) + +        return embed + +    @staticmethod +    def build_error_embed(user: discord.Member) -> discord.Embed: +        """Builds an error embed for when a bookmark requester has DMs disabled.""" +        return discord.Embed( +            title=random.choice(ERROR_REPLIES), +            description=f"{user.mention}, please enable your DMs to receive the bookmark.", +            colour=Colours.soft_red +        ) + +    async def action_bookmark( +        self, +        channel: discord.TextChannel, +        user: discord.Member, +        target_message: discord.Message, +        title: str +    ) -> None: +        """Sends the bookmark DM, or sends an error embed when a user bookmarks a message.""" +        try: +            embed = self.build_bookmark_dm(target_message, title) +            await user.send(embed=embed) +        except discord.Forbidden: +            error_embed = self.build_error_embed(user) +            await channel.send(embed=error_embed) +        else: +            log.info(f"{user} bookmarked {target_message.jump_url} with title '{title}'") + +    @staticmethod +    async def send_reaction_embed( +        channel: discord.TextChannel, +        target_message: discord.Message +    ) -> discord.Message: +        """Sends an embed, with a reaction, so users can react to bookmark the message too.""" +        message = await channel.send( +            embed=discord.Embed( +                description=( +                    f"React with {BOOKMARK_EMOJI} to be sent your very own bookmark to " +                    f"[this message]({target_message.jump_url})." +                ), +                colour=Colours.soft_green +            ) +        ) + +        await message.add_reaction(BOOKMARK_EMOJI) +        return message +      @commands.command(name="bookmark", aliases=("bm", "pin"))      async def bookmark(          self, @@ -28,7 +96,7 @@ class Bookmark(commands.Cog):          # 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") +            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, @@ -37,29 +105,40 @@ class Bookmark(commands.Cog):              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=Icons.bookmark) - -        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 +        def event_check(reaction: discord.Reaction, user: discord.Member) -> bool: +            """Make sure that this reaction is what we want to operate on.""" +            return ( +                # Conditions for a successful pagination: +                all(( +                    # Reaction is on this message +                    reaction.message.id == reaction_message.id, +                    # User has not already bookmarked this message +                    user.id not in bookmarked_users, +                    # Reaction is the `BOOKMARK_EMOJI` emoji +                    str(reaction.emoji) == BOOKMARK_EMOJI, +                    # Reaction was not made by the Bot +                    user.id != self.bot.user.id +                ))              ) -            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) +        await self.action_bookmark(ctx.channel, ctx.author, target_message, title) + +        # Keep track of who has already bookmarked, so users can't spam reactions and cause loads of DMs +        bookmarked_users = [ctx.author.id] +        reaction_message = await self.send_reaction_embed(ctx.channel, target_message) + +        while True: +            try: +                _, user = await self.bot.wait_for("reaction_add", timeout=TIMEOUT, check=event_check) +            except asyncio.TimeoutError: +                log.debug("Timed out waiting for a reaction") +                break +            log.trace(f"{user} has successfully bookmarked from a reaction, attempting to DM them.") +            await self.action_bookmark(ctx.channel, user, target_message, title) +            bookmarked_users.append(user.id) + +        await reaction_message.delete() -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the Bookmark cog."""      bot.add_cog(Bookmark(bot)) diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py new file mode 100644 index 00000000..32dfae09 --- /dev/null +++ b/bot/exts/evergreen/catify.py @@ -0,0 +1,86 @@ +import random +from contextlib import suppress +from typing import Optional + +from discord import AllowedMentions, Embed, Forbidden +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Cats, Colours, NEGATIVE_REPLIES +from bot.utils import helpers + + +class Catify(commands.Cog): +    """Cog for the catify command.""" + +    @commands.command(aliases=("ᓚᘏᗢify", "ᓚᘏᗢ")) +    @commands.cooldown(1, 5, commands.BucketType.user) +    async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None: +        """ +        Convert the provided text into a cat themed sentence by interspercing cats throughout text. + +        If no text is given then the users nickname is edited. +        """ +        if not text: +            display_name = ctx.author.display_name + +            if len(display_name) > 26: +                embed = Embed( +                    title=random.choice(NEGATIVE_REPLIES), +                    description=( +                        "Your display name is too long to be catified! " +                        "Please change it to be under 26 characters." +                    ), +                    color=Colours.soft_red +                ) +                await ctx.send(embed=embed) +                return + +            else: +                display_name += f" | {random.choice(Cats.cats)}" + +                await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) + +                with suppress(Forbidden): +                    await ctx.author.edit(nick=display_name) +        else: +            if len(text) >= 1500: +                embed = Embed( +                    title=random.choice(NEGATIVE_REPLIES), +                    description="Submitted text was too large! Please submit something under 1500 characters.", +                    color=Colours.soft_red +                ) +                await ctx.send(embed=embed) +                return + +            string_list = text.split() +            for index, name in enumerate(string_list): +                name = name.lower() +                if "cat" in name: +                    if random.randint(0, 5) == 5: +                        string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") +                    else: +                        string_list[index] = name.replace("cat", random.choice(Cats.cats)) +                for element in Cats.cats: +                    if element in name: +                        string_list[index] = name.replace(element, "cat") + +            string_len = len(string_list) // 3 or len(string_list) + +            for _ in range(random.randint(1, string_len)): +                # insert cat at random index +                if random.randint(0, 5) == 5: +                    string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**") +                else: +                    string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats)) + +            text = helpers.suppress_links(" ".join(string_list)) +            await ctx.send( +                f">>> {text}", +                allowed_mentions=AllowedMentions.none() +            ) + + +def setup(bot: Bot) -> None: +    """Loads the catify cog.""" +    bot.add_cog(Catify()) diff --git a/bot/exts/evergreen/cheatsheet.py b/bot/exts/evergreen/cheatsheet.py index 3fe709d5..ae7793c9 100644 --- a/bot/exts/evergreen/cheatsheet.py +++ b/bot/exts/evergreen/cheatsheet.py @@ -8,6 +8,7 @@ from discord.ext import commands  from discord.ext.commands import BucketType, Context  from bot import constants +from bot.bot import Bot  from bot.constants import Categories, Channels, Colours, ERROR_REPLIES  from bot.utils.decorators import whitelist_override @@ -23,17 +24,17 @@ Unknown cheat sheet. Please try to reformulate your query.  If the problem persists send a message in <#{Channels.dev_contrib}>  """ -URL = 'https://cheat.sh/python/{search}' +URL = "https://cheat.sh/python/{search}"  ESCAPE_TT = str.maketrans({"`": "\\`"})  ANSI_RE = re.compile(r"\x1b\[.*?m")  # We need to pass headers as curl otherwise it would default to aiohttp which would return raw html. -HEADERS = {'User-Agent': 'curl/7.68.0'} +HEADERS = {"User-Agent": "curl/7.68.0"}  class CheatSheet(commands.Cog):      """Commands that sends a result of a cht.sh search in code blocks.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @staticmethod @@ -60,14 +61,18 @@ class CheatSheet(commands.Cog):          body_space = min(1986 - len(url), 1000)          if len(body_text) > body_space: -            description = (f"**Result Of cht.sh**\n" -                           f"```python\n{body_text[:body_space]}\n" -                           f"... (truncated - too many lines)```\n" -                           f"Full results: {url} ") +            description = ( +                f"**Result Of cht.sh**\n" +                f"```python\n{body_text[:body_space]}\n" +                f"... (truncated - too many lines)```\n" +                f"Full results: {url} " +            )          else: -            description = (f"**Result Of cht.sh**\n" -                           f"```python\n{body_text}```\n" -                           f"{url}") +            description = ( +                f"**Result Of cht.sh**\n" +                f"```python\n{body_text}```\n" +                f"{url}" +            )          return False, description      @commands.command( @@ -102,6 +107,6 @@ class CheatSheet(commands.Cog):                  await ctx.send(content=description) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the CheatSheet cog."""      bot.add_cog(CheatSheet(bot)) diff --git a/bot/exts/evergreen/connect_four.py b/bot/exts/evergreen/connect_four.py index 7e3ec42b..5c82ffee 100644 --- a/bot/exts/evergreen/connect_four.py +++ b/bot/exts/evergreen/connect_four.py @@ -8,6 +8,7 @@ import emojis  from discord.ext import commands  from discord.ext.commands import guild_only +from bot.bot import Bot  from bot.constants import Emojis  NUMBERS = list(Emojis.number_emojis.values()) @@ -21,13 +22,13 @@ class Game:      """A Connect 4 Game."""      def __init__( -            self, -            bot: commands.Bot, -            channel: discord.TextChannel, -            player1: discord.Member, -            player2: typing.Optional[discord.Member], -            tokens: typing.List[str], -            size: int = 7 +        self, +        bot: Bot, +        channel: discord.TextChannel, +        player1: discord.Member, +        player2: typing.Optional[discord.Member], +        tokens: typing.List[str], +        size: int = 7      ) -> None:          self.bot = bot @@ -54,8 +55,8 @@ class Game:      async def print_grid(self) -> None:          """Formats and outputs the Connect Four grid to the channel."""          title = ( -            f'Connect 4: {self.player1.display_name}' -            f' VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}' +            f"Connect 4: {self.player1.display_name}" +            f" VS {self.bot.user.display_name if isinstance(self.player2, AI) else self.player2.display_name}"          )          rows = [" ".join(self.tokens[s] for s in row) for row in self.grid] @@ -66,7 +67,7 @@ class Game:          if self.message:              await self.message.edit(embed=embed)          else: -            self.message = await self.channel.send(content='Loading...') +            self.message = await self.channel.send(content="Loading...")              for emoji in self.unicode_numbers:                  await self.message.add_reaction(emoji)              await self.message.add_reaction(CROSS_EMOJI) @@ -180,7 +181,7 @@ class Game:  class AI:      """The Computer Player for Single-Player games.""" -    def __init__(self, bot: commands.Bot, game: Game) -> None: +    def __init__(self, bot: Bot, game: Game) -> None:          self.game = game          self.mention = bot.user.mention @@ -255,7 +256,7 @@ class AI:  class ConnectFour(commands.Cog):      """Connect Four. The Classic Vertical Four-in-a-row Game!""" -    def __init__(self, bot: commands.Bot) -> None: +    def __init__(self, bot: Bot) -> None:          self.bot = bot          self.games: typing.List[Game] = []          self.waiting: typing.List[discord.Member] = [] @@ -276,27 +277,29 @@ class ConnectFour(commands.Cog):              return False          if not self.min_board_size <= board_size <= self.max_board_size: -            await ctx.send(f"{board_size} is not a valid board size. A valid board size is " -                           f"between `{self.min_board_size}` and `{self.max_board_size}`.") +            await ctx.send( +                f"{board_size} is not a valid board size. A valid board size is " +                f"between `{self.min_board_size}` and `{self.max_board_size}`." +            )              return False          return True      def get_player( -            self, -            ctx: commands.Context, -            announcement: discord.Message, -            reaction: discord.Reaction, -            user: discord.Member +        self, +        ctx: commands.Context, +        announcement: discord.Message, +        reaction: discord.Reaction, +        user: discord.Member      ) -> bool:          """Predicate checking the criteria for the announcement message."""          if self.already_playing(ctx.author):  # If they've joined a game since requesting a player 2              return True  # Is dealt with later on          if ( -                user.id not in (ctx.me.id, ctx.author.id) -                and str(reaction.emoji) == Emojis.hand_raised -                and reaction.message.id == announcement.id +            user.id not in (ctx.me.id, ctx.author.id) +            and str(reaction.emoji) == Emojis.hand_raised +            and reaction.message.id == announcement.id          ):              if self.already_playing(user):                  self.bot.loop.create_task(ctx.send(f"{user.mention} You're already playing a game!")) @@ -313,9 +316,9 @@ class ConnectFour(commands.Cog):              return True          if ( -                user.id == ctx.author.id -                and str(reaction.emoji) == CROSS_EMOJI -                and reaction.message.id == announcement.id +            user.id == ctx.author.id +            and str(reaction.emoji) == CROSS_EMOJI +            and reaction.message.id == announcement.id          ):              return True          return False @@ -326,7 +329,7 @@ class ConnectFour(commands.Cog):      @staticmethod      def check_emojis( -            e1: EMOJI_CHECK, e2: EMOJI_CHECK +        e1: EMOJI_CHECK, e2: EMOJI_CHECK      ) -> typing.Tuple[bool, typing.Optional[str]]:          """Validate the emojis, the user put."""          if isinstance(e1, str) and emojis.count(e1) != 1: @@ -336,12 +339,12 @@ class ConnectFour(commands.Cog):          return True, None      async def _play_game( -            self, -            ctx: commands.Context, -            user: typing.Optional[discord.Member], -            board_size: int, -            emoji1: str, -            emoji2: str +        self, +        ctx: commands.Context, +        user: typing.Optional[discord.Member], +        board_size: int, +        emoji1: str, +        emoji2: str      ) -> None:          """Helper for playing a game of connect four."""          self.tokens = [":white_circle:", str(emoji1), str(emoji2)] @@ -354,7 +357,7 @@ class ConnectFour(commands.Cog):              self.games.remove(game)          except Exception:              # End the game in the event of an unforeseen error so the players aren't stuck in a game -            await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed") +            await ctx.send(f"{ctx.author.mention} {user.mention if user else ''} An error occurred. Game failed.")              if game in self.games:                  self.games.remove(game)              raise @@ -362,15 +365,15 @@ class ConnectFour(commands.Cog):      @guild_only()      @commands.group(          invoke_without_command=True, -        aliases=["4inarow", "connect4", "connectfour", "c4"], +        aliases=("4inarow", "connect4", "connectfour", "c4"),          case_insensitive=True      )      async def connect_four( -            self, -            ctx: commands.Context, -            board_size: int = 7, -            emoji1: EMOJI_CHECK = "\U0001f535", -            emoji2: EMOJI_CHECK = "\U0001f534" +        self, +        ctx: commands.Context, +        board_size: int = 7, +        emoji1: EMOJI_CHECK = "\U0001f535", +        emoji2: EMOJI_CHECK = "\U0001f534"      ) -> None:          """          Play the classic game of Connect Four with someone! @@ -425,13 +428,13 @@ class ConnectFour(commands.Cog):          await self._play_game(ctx, user, board_size, str(emoji1), str(emoji2))      @guild_only() -    @connect_four.command(aliases=["bot", "computer", "cpu"]) +    @connect_four.command(aliases=("bot", "computer", "cpu"))      async def ai( -            self, -            ctx: commands.Context, -            board_size: int = 7, -            emoji1: EMOJI_CHECK = "\U0001f535", -            emoji2: EMOJI_CHECK = "\U0001f534" +        self, +        ctx: commands.Context, +        board_size: int = 7, +        emoji1: EMOJI_CHECK = "\U0001f535", +        emoji2: EMOJI_CHECK = "\U0001f534"      ) -> None:          """Play Connect Four against a computer player."""          check, emoji = self.check_emojis(emoji1, emoji2) @@ -445,6 +448,6 @@ class ConnectFour(commands.Cog):          await self._play_game(ctx, None, board_size, str(emoji1), str(emoji2)) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load ConnectFour Cog."""      bot.add_cog(ConnectFour(bot)) diff --git a/bot/exts/evergreen/conversationstarters.py b/bot/exts/evergreen/conversationstarters.py index e7058961..fdc4467a 100644 --- a/bot/exts/evergreen/conversationstarters.py +++ b/bot/exts/evergreen/conversationstarters.py @@ -4,11 +4,12 @@ import yaml  from discord import Color, Embed  from discord.ext import commands +from bot.bot import Bot  from bot.constants import WHITELISTED_CHANNELS  from bot.utils.decorators import whitelist_override  from bot.utils.randomization import RandomCycle -SUGGESTION_FORM = 'https://forms.gle/zw6kkJqv8U43Nfjg9' +SUGGESTION_FORM = "https://forms.gle/zw6kkJqv8U43Nfjg9"  with Path("bot/resources/evergreen/starter.yaml").open("r", encoding="utf8") as f:      STARTERS = yaml.load(f, Loader=yaml.FullLoader) @@ -24,9 +25,9 @@ with Path("bot/resources/evergreen/py_topics.yaml").open("r", encoding="utf8") a      ALL_ALLOWED_CHANNELS = list(PY_TOPICS.keys()) + list(WHITELISTED_CHANNELS)  # Putting all topics into one dictionary and shuffling lists to reduce same-topic repetitions. -ALL_TOPICS = {'default': STARTERS, **PY_TOPICS} +ALL_TOPICS = {"default": STARTERS, **PY_TOPICS}  TOPICS = { -    channel: RandomCycle(topics or ['No topics found for this channel.']) +    channel: RandomCycle(topics or ["No topics found for this channel."])      for channel, topics in ALL_TOPICS.items()  } @@ -34,9 +35,6 @@ TOPICS = {  class ConvoStarters(commands.Cog):      """Evergreen conversation topics.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @commands.command()      @whitelist_override(channels=ALL_ALLOWED_CHANNELS)      async def topic(self, ctx: commands.Context) -> None: @@ -48,7 +46,7 @@ class ConvoStarters(commands.Cog):          Otherwise, a random conversation topic will be received by the user.          """          # No matter what, the form will be shown. -        embed = Embed(description=f'Suggest more topics [here]({SUGGESTION_FORM})!', color=Color.blurple()) +        embed = Embed(description=f"Suggest more topics [here]({SUGGESTION_FORM})!", color=Color.blurple())          try:              # Fetching topics. @@ -56,16 +54,16 @@ class ConvoStarters(commands.Cog):          # If the channel isn't Python-related.          except KeyError: -            embed.title = f'**{next(TOPICS["default"])}**' +            embed.title = f"**{next(TOPICS['default'])}**"          # If the channel ID doesn't have any topics.          else: -            embed.title = f'**{next(channel_topics)}**' +            embed.title = f"**{next(channel_topics)}**"          finally:              await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Conversation starters Cog load.""" -    bot.add_cog(ConvoStarters(bot)) +def setup(bot: Bot) -> None: +    """Load the ConvoStarters cog.""" +    bot.add_cog(ConvoStarters()) diff --git a/bot/exts/evergreen/emoji.py b/bot/exts/evergreen/emoji.py index fa3044e3..11615214 100644 --- a/bot/exts/evergreen/emoji.py +++ b/bot/exts/evergreen/emoji.py @@ -8,6 +8,7 @@ from typing import List, Optional, Tuple  from discord import Color, Embed, Emoji  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours, ERROR_REPLIES  from bot.utils.extensions import invoke_help_command  from bot.utils.pagination import LinePaginator @@ -19,9 +20,6 @@ log = logging.getLogger(__name__)  class Emojis(commands.Cog):      """A collection of commands related to emojis in the server.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @staticmethod      def embed_builder(emoji: dict) -> Tuple[Embed, List[str]]:          """Generates an embed with the emoji names and count.""" @@ -48,9 +46,9 @@ class Emojis(commands.Cog):                  else:                      emoji_info = f"There is **{len(category_emojis)}** emoji in the **{category_name}** category."                  if emoji_choice.animated: -                    msg.append(f'<a:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}') +                    msg.append(f"<a:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}")                  else: -                    msg.append(f'<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}') +                    msg.append(f"<:{emoji_choice.name}:{emoji_choice.id}> {emoji_info}")          return embed, msg      @staticmethod @@ -66,7 +64,7 @@ class Emojis(commands.Cog):          for emoji in emojis:              emoji_dict[emoji.name.split("_")[0]].append(emoji) -        error_comp = ', '.join(emoji_dict) +        error_comp = ", ".join(emoji_dict)          msg.append(f"These are the valid emoji categories:\n```{error_comp}```")          return embed, msg @@ -86,7 +84,7 @@ class Emojis(commands.Cog):          if not ctx.guild.emojis:              await ctx.send("No emojis found.")              return -        log.trace(f"Emoji Category {'' if category_query else 'not '}provided by the user") +        log.trace(f"Emoji Category {'' if category_query else 'not '}provided by the user.")          for emoji in ctx.guild.emojis:              emoji_category = emoji.name.split("_")[0] @@ -120,6 +118,6 @@ class Emojis(commands.Cog):          await ctx.send(embed=emoji_information) -def setup(bot: commands.Bot) -> None: -    """Add the Emojis cog into the bot.""" -    bot.add_cog(Emojis(bot)) +def setup(bot: Bot) -> None: +    """Load the Emojis cog.""" +    bot.add_cog(Emojis()) diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py index 8db49748..de8e53d0 100644 --- a/bot/exts/evergreen/error_handler.py +++ b/bot/exts/evergreen/error_handler.py @@ -7,6 +7,7 @@ from discord import Embed, Message  from discord.ext import commands  from sentry_sdk import push_scope +from bot.bot import Bot  from bot.constants import Channels, Colours, ERROR_REPLIES, NEGATIVE_REPLIES  from bot.utils.decorators import InChannelCheckFailure, InMonthCheckFailure  from bot.utils.exceptions import UserNotPlayingError @@ -17,9 +18,6 @@ log = logging.getLogger(__name__)  class CommandErrorHandler(commands.Cog):      """A error handler for the PythonDiscord server.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @staticmethod      def revert_cooldown_counter(command: commands.Command, message: Message) -> None:          """Undoes the last cooldown counter for user-error cases.""" @@ -41,8 +39,8 @@ class CommandErrorHandler(commands.Cog):      @commands.Cog.listener()      async def on_command_error(self, ctx: commands.Context, error: commands.CommandError) -> None: -        """Activates when a command opens an error.""" -        if getattr(error, 'handled', False): +        """Activates when a command raises an error.""" +        if getattr(error, "handled", False):              logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.")              return @@ -51,7 +49,7 @@ class CommandErrorHandler(commands.Cog):              parent_command = f"{ctx.command} "              ctx = subctx -        error = getattr(error, 'original', error) +        error = getattr(error, "original", error)          logging.debug(              f"Error Encountered: {type(error).__name__} - {str(error)}, "              f"Command: {ctx.command}, " @@ -127,14 +125,11 @@ class CommandErrorHandler(commands.Cog):              scope.set_extra("full_message", ctx.message.content)              if ctx.guild is not None: -                scope.set_extra( -                    "jump_to", -                    f"https://discordapp.com/channels/{ctx.guild.id}/{ctx.channel.id}/{ctx.message.id}" -                ) +                scope.set_extra("jump_to", ctx.message.jump_url)              log.exception(f"Unhandled command error: {str(error)}", exc_info=error) -def setup(bot: commands.Bot) -> None: -    """Error handler Cog load.""" -    bot.add_cog(CommandErrorHandler(bot)) +def setup(bot: Bot) -> None: +    """Load the ErrorHandler cog.""" +    bot.add_cog(CommandErrorHandler()) diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py index 101725da..3b266e1b 100644 --- a/bot/exts/evergreen/fun.py +++ b/bot/exts/evergreen/fun.py @@ -7,10 +7,12 @@ from typing import Callable, Iterable, Tuple, Union  from discord import Embed, Message  from discord.ext import commands -from discord.ext.commands import BadArgument, Bot, Cog, Context, MessageConverter, clean_content +from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content  from bot import utils +from bot.bot import Bot  from bot.constants import Client, Colours, Emojis +from bot.utils import helpers  log = logging.getLogger(__name__) @@ -54,8 +56,7 @@ class Fun(Cog):      def __init__(self, bot: Bot) -> None:          self.bot = bot -        with Path("bot/resources/evergreen/caesar_info.json").open("r", encoding="UTF-8") as f: -            self._caesar_cipher_embed = json.load(f) +        self._caesar_cipher_embed = json.loads(Path("bot/resources/evergreen/caesar_info.json").read_text("UTF-8"))      @staticmethod      def _get_random_die() -> str: @@ -83,6 +84,7 @@ class Fun(Cog):          if embed is not None:              embed = Fun._convert_embed(conversion_func, embed)          converted_text = conversion_func(text) +        converted_text = helpers.suppress_links(converted_text)          # Don't put >>> if only embed present          if converted_text:              converted_text = f">>> {converted_text.lstrip('> ')}" @@ -101,6 +103,7 @@ class Fun(Cog):          if embed is not None:              embed = Fun._convert_embed(conversion_func, embed)          converted_text = conversion_func(text) +        converted_text = helpers.suppress_links(converted_text)          # Don't put >>> if only embed present          if converted_text:              converted_text = f">>> {converted_text.lstrip('> ')}" @@ -239,6 +242,6 @@ class Fun(Cog):          return Embed.from_dict(embed_dict) -def setup(bot: commands.Bot) -> None: -    """Fun Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Fun cog."""      bot.add_cog(Fun(bot)) diff --git a/bot/exts/evergreen/game.py b/bot/exts/evergreen/game.py index 068d3f68..32fe9263 100644 --- a/bot/exts/evergreen/game.py +++ b/bot/exts/evergreen/game.py @@ -176,7 +176,7 @@ class Games(Cog):                              "Invalid OAuth credentials. Unloading Games cog. "                              f"OAuth response message: {result['message']}"                          ) -                        self.bot.remove_cog('Games') +                        self.bot.remove_cog("Games")                      return @@ -224,8 +224,8 @@ class Games(Cog):              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: +    @group(name="games", aliases=("game",), invoke_without_command=True) +    async def games(self, ctx: Context, amount: Optional[int] = 5, *, genre: Optional[str]) -> None:          """          Get random game(s) by genre from IGDB. Use .games genres command to get all available genres. @@ -277,7 +277,7 @@ class Games(Cog):          await ImagePaginator.paginate(pages, ctx, Embed(title=f"Random {genre.title()} Games")) -    @games.command(name="top", aliases=["t"]) +    @games.command(name="top", aliases=("t",))      async def top(self, ctx: Context, amount: int = 10) -> None:          """          Get current Top games in IGDB. @@ -294,19 +294,19 @@ class Games(Cog):          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"]) +    @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"]) +    @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"]) +    @games.command(name="company", aliases=("companies",))      async def company(self, ctx: Context, amount: int = 5) -> None:          """          Get random Game Companies companies from IGDB API. @@ -325,7 +325,7 @@ class Games(Cog):          await ImagePaginator.paginate(pages, ctx, Embed(title="Random Game Companies"))      @with_role(*STAFF_ROLES) -    @games.command(name="refresh", aliases=["r"]) +    @games.command(name="refresh", aliases=("r",))      async def refresh_genres_command(self, ctx: Context) -> None:          """Refresh .games command genres."""          try: @@ -335,13 +335,14 @@ class Games(Cog):              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]]: +    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. @@ -373,8 +374,10 @@ class Games(Cog):          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 "?" +        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 "?" @@ -471,7 +474,7 @@ class Games(Cog):  def setup(bot: Bot) -> None: -    """Add/Load Games cog.""" +    """Load the Games cog."""      # Check does IGDB API key exist, if not, log warning and don't load cog      if not Tokens.igdb_client_id:          logger.warning("No IGDB client ID. Not loading Games cog.") diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py index c8a6b3f7..27e607e5 100644 --- a/bot/exts/evergreen/githubinfo.py +++ b/bot/exts/evergreen/githubinfo.py @@ -5,8 +5,8 @@ from urllib.parse import quote  import discord  from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType +from bot.bot import Bot  from bot.constants import Colours, NEGATIVE_REPLIES  from bot.exts.utils.extensions import invoke_help_command @@ -18,7 +18,7 @@ GITHUB_API_URL = "https://api.github.com"  class GithubInfo(commands.Cog):      """Fetches info from GitHub.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      async def fetch_data(self, url: str) -> dict: @@ -26,14 +26,14 @@ class GithubInfo(commands.Cog):          async with self.bot.http_session.get(url) as r:              return await r.json() -    @commands.group(name='github', aliases=('gh', 'git')) -    @commands.cooldown(1, 10, BucketType.user) +    @commands.group(name="github", aliases=("gh", "git")) +    @commands.cooldown(1, 10, commands.BucketType.user)      async def github_group(self, ctx: commands.Context) -> None:          """Commands for finding information related to GitHub."""          if ctx.invoked_subcommand is None:              await invoke_help_command(ctx) -    @github_group.command(name='user', aliases=('userinfo',)) +    @github_group.command(name="user", aliases=("userinfo",))      async def github_user_info(self, ctx: commands.Context, username: str) -> None:          """Fetches a user's GitHub information."""          async with ctx.typing(): @@ -50,31 +50,31 @@ class GithubInfo(commands.Cog):                  await ctx.send(embed=embed)                  return -            org_data = await self.fetch_data(user_data['organizations_url']) +            org_data = await self.fetch_data(user_data["organizations_url"])              orgs = [f"[{org['login']}](https://github.com/{org['login']})" for org in org_data] -            orgs_to_add = ' | '.join(orgs) +            orgs_to_add = " | ".join(orgs) -            gists = user_data['public_gists'] +            gists = user_data["public_gists"]              # Forming blog link -            if user_data['blog'].startswith("http"):  # Blog link is complete -                blog = user_data['blog'] -            elif user_data['blog']:  # Blog exists but the link is not complete +            if user_data["blog"].startswith("http"):  # Blog link is complete +                blog = user_data["blog"] +            elif user_data["blog"]:  # Blog exists but the link is not complete                  blog = f"https://{user_data['blog']}"              else:                  blog = "No website link available"              embed = discord.Embed(                  title=f"`{user_data['login']}`'s GitHub profile info", -                description=f"```{user_data['bio']}```\n" if user_data['bio'] is not None else "", +                description=f"```{user_data['bio']}```\n" if user_data["bio"] else "",                  colour=discord.Colour.blurple(), -                url=user_data['html_url'], -                timestamp=datetime.strptime(user_data['created_at'], "%Y-%m-%dT%H:%M:%SZ") +                url=user_data["html_url"], +                timestamp=datetime.strptime(user_data["created_at"], "%Y-%m-%dT%H:%M:%SZ")              ) -            embed.set_thumbnail(url=user_data['avatar_url']) +            embed.set_thumbnail(url=user_data["avatar_url"])              embed.set_footer(text="Account created at") -            if user_data['type'] == "User": +            if user_data["type"] == "User":                  embed.add_field(                      name="Followers", @@ -90,12 +90,12 @@ class GithubInfo(commands.Cog):                  value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)"              ) -            if user_data['type'] == "User": +            if user_data["type"] == "User":                  embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{quote(username, safe='')})")                  embed.add_field(                      name=f"Organization{'s' if len(orgs)!=1 else ''}", -                    value=orgs_to_add if orgs else "No organizations" +                    value=orgs_to_add if orgs else "No organizations."                  )              embed.add_field(name="Website", value=blog) @@ -108,8 +108,8 @@ class GithubInfo(commands.Cog):          The repository should look like `user/reponame` or `user reponame`.          """ -        repo = '/'.join(repo) -        if repo.count('/') != 1: +        repo = "/".join(repo) +        if repo.count("/") != 1:              embed = discord.Embed(                  title=random.choice(NEGATIVE_REPLIES),                  description="The repository should look like `user/reponame` or `user reponame`.", @@ -134,10 +134,10 @@ class GithubInfo(commands.Cog):                  return          embed = discord.Embed( -            title=repo_data['name'], +            title=repo_data["name"],              description=repo_data["description"],              colour=discord.Colour.blurple(), -            url=repo_data['html_url'] +            url=repo_data["html_url"]          )          # If it's a fork, then it will have a parent key @@ -147,7 +147,7 @@ class GithubInfo(commands.Cog):          except KeyError:              log.debug("Repository is not a fork.") -        repo_owner = repo_data['owner'] +        repo_owner = repo_data["owner"]          embed.set_author(              name=repo_owner["login"], @@ -155,8 +155,8 @@ class GithubInfo(commands.Cog):              icon_url=repo_owner["avatar_url"]          ) -        repo_created_at = datetime.strptime(repo_data['created_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y") -        last_pushed = datetime.strptime(repo_data['pushed_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M") +        repo_created_at = datetime.strptime(repo_data["created_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y") +        last_pushed = datetime.strptime(repo_data["pushed_at"], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M")          embed.set_footer(              text=( @@ -170,6 +170,6 @@ class GithubInfo(commands.Cog):          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Adding the cog to the bot.""" +def setup(bot: Bot) -> None: +    """Load the GithubInfo cog."""      bot.add_cog(GithubInfo(bot)) diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py index 91147243..3c9ba4d2 100644 --- a/bot/exts/evergreen/help.py +++ b/bot/exts/evergreen/help.py @@ -2,9 +2,8 @@  import asyncio  import itertools  import logging -from collections import namedtuple  from contextlib import suppress -from typing import Union +from typing import List, NamedTuple, Union  from discord import Colour, Embed, HTTPException, Message, Reaction, User  from discord.ext import commands @@ -22,14 +21,21 @@ from bot.utils.pagination import (  DELETE_EMOJI = Emojis.trashcan  REACTIONS = { -    FIRST_EMOJI: 'first', -    LEFT_EMOJI: 'back', -    RIGHT_EMOJI: 'next', -    LAST_EMOJI: 'end', -    DELETE_EMOJI: 'stop', +    FIRST_EMOJI: "first", +    LEFT_EMOJI: "back", +    RIGHT_EMOJI: "next", +    LAST_EMOJI: "end", +    DELETE_EMOJI: "stop",  } -Cog = namedtuple('Cog', ['name', 'description', 'commands']) + +class Cog(NamedTuple): +    """Show information about a Cog's name, description and commands.""" + +    name: str +    description: str +    commands: List[Command] +  log = logging.getLogger(__name__) @@ -87,7 +93,7 @@ class HelpSession:          # set the query details for the session          if command: -            query_str = ' '.join(command) +            query_str = " ".join(command)              self.query = self._get_query(query_str)              self.description = self.query.description or self.query.help          else: @@ -191,7 +197,7 @@ class HelpSession:          self.reset_timeout()          # Run relevant action method -        action = getattr(self, f'do_{REACTIONS[emoji]}', None) +        action = getattr(self, f"do_{REACTIONS[emoji]}", None)          if action:              await action() @@ -234,11 +240,11 @@ class HelpSession:          if cmd.cog:              try:                  if cmd.cog.category: -                    return f'**{cmd.cog.category}**' +                    return f"**{cmd.cog.category}**"              except AttributeError:                  pass -            return f'**{cmd.cog_name}**' +            return f"**{cmd.cog_name}**"          else:              return "**\u200bNo Category:**" @@ -262,139 +268,143 @@ class HelpSession:                  # if default is not an empty string or None                  if show_default: -                    results.append(f'[{name}={param.default}]') +                    results.append(f"[{name}={param.default}]")                  else: -                    results.append(f'[{name}]') +                    results.append(f"[{name}]")              # if variable length argument              elif param.kind == param.VAR_POSITIONAL: -                results.append(f'[{name}...]') +                results.append(f"[{name}...]")              # if required              else: -                results.append(f'<{name}>') +                results.append(f"<{name}>")          return f"{cmd.name} {' '.join(results)}"      async def build_pages(self) -> None:          """Builds the list of content pages to be paginated through in the help message, as a list of str."""          # Use LinePaginator to restrict embed line height -        paginator = LinePaginator(prefix='', suffix='', max_lines=self._max_lines) - -        prefix = constants.Client.prefix +        paginator = LinePaginator(prefix="", suffix="", max_lines=self._max_lines)          # show signature if query is a command          if isinstance(self.query, commands.Command): -            signature = self._get_command_params(self.query) -            parent = self.query.full_parent_name + ' ' if self.query.parent else '' -            paginator.add_line(f'**```{prefix}{parent}{signature}```**') - -            aliases = ', '.join(f'`{a}`' for a in self.query.aliases) -            if aliases: -                paginator.add_line(f'**Can also use:** {aliases}\n') - -            if not await self.query.can_run(self._ctx): -                paginator.add_line('***You cannot run this command.***\n') +            await self._add_command_signature(paginator)          if isinstance(self.query, Cog): -            paginator.add_line(f'**{self.query.name}**') +            paginator.add_line(f"**{self.query.name}**")          if self.description: -            paginator.add_line(f'*{self.description}*') +            paginator.add_line(f"*{self.description}*")          # list all children commands of the queried object          if isinstance(self.query, (commands.GroupMixin, Cog)): +            await self._list_child_commands(paginator) -            # remove hidden commands if session is not wanting hiddens -            if not self._show_hidden: -                filtered = [c for c in self.query.commands if not c.hidden] -            else: -                filtered = self.query.commands - -            # if after filter there are no commands, finish up -            if not filtered: -                self._pages = paginator.pages -                return - -            if isinstance(self.query, Cog): -                grouped = (('**Commands:**', self.query.commands),) - -            elif isinstance(self.query, commands.Command): -                grouped = (('**Subcommands:**', self.query.commands),) - -                # don't show prefix for subcommands -                prefix = '' +        self._pages = paginator.pages -            # otherwise sort and organise all commands into categories -            else: -                cat_sort = sorted(filtered, key=self._category_key) -                grouped = itertools.groupby(cat_sort, key=self._category_key) +    async def _add_command_signature(self, paginator: LinePaginator) -> None: +        prefix = constants.Client.prefix -            for category, cmds in grouped: -                cmds = sorted(cmds, key=lambda c: c.name) +        signature = self._get_command_params(self.query) +        parent = self.query.full_parent_name + " " if self.query.parent else "" +        paginator.add_line(f"**```{prefix}{parent}{signature}```**") +        aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in self.query.aliases] +        aliases += [f"`{alias}`" for alias in getattr(self.query, "root_aliases", ())] +        aliases = ", ".join(sorted(aliases)) +        if aliases: +            paginator.add_line(f"**Can also use:** {aliases}\n") +        if not await self.query.can_run(self._ctx): +            paginator.add_line("***You cannot run this command.***\n") + +    async def _list_child_commands(self, paginator: LinePaginator) -> None: +        # remove hidden commands if session is not wanting hiddens +        if not self._show_hidden: +            filtered = [c for c in self.query.commands if not c.hidden] +        else: +            filtered = self.query.commands -                if len(cmds) == 0: -                    continue +        # if after filter there are no commands, finish up +        if not filtered: +            self._pages = paginator.pages +            return -                cat_cmds = [] +        if isinstance(self.query, Cog): +            grouped = (("**Commands:**", self.query.commands),) -                for command in cmds: +        elif isinstance(self.query, commands.Command): +            grouped = (("**Subcommands:**", self.query.commands),) -                    # skip if hidden and hide if session is set to -                    if command.hidden and not self._show_hidden: -                        continue +        # otherwise sort and organise all commands into categories +        else: +            cat_sort = sorted(filtered, key=self._category_key) +            grouped = itertools.groupby(cat_sort, key=self._category_key) -                    # see if the user can run the command -                    strikeout = '' +        for category, cmds in grouped: +            await self._format_command_category(paginator, category, list(cmds)) -                    # Patch to make the !help command work outside of #bot-commands again -                    # This probably needs a proper rewrite, but this will make it work in -                    # the mean time. -                    try: -                        can_run = await command.can_run(self._ctx) -                    except CheckFailure: -                        can_run = False +    async def _format_command_category(self, paginator: LinePaginator, category: str, cmds: List[Command]) -> None: +        cmds = sorted(cmds, key=lambda c: c.name) +        cat_cmds = [] +        for command in cmds: +            cat_cmds += await self._format_command(command) -                    if not can_run: -                        # skip if we don't show commands they can't run -                        if self._only_can_run: -                            continue -                        strikeout = '~~' +        # state var for if the category should be added next +        print_cat = 1 +        new_page = True -                    signature = self._get_command_params(command) -                    info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" +        for details in cat_cmds: -                    # handle if the command has no docstring -                    if command.short_doc: -                        cat_cmds.append(f'{info}\n*{command.short_doc}*') -                    else: -                        cat_cmds.append(f'{info}\n*No details provided.*') +            # keep details together, paginating early if it won"t fit +            lines_adding = len(details.split("\n")) + print_cat +            if paginator._linecount + lines_adding > self._max_lines: +                paginator._linecount = 0 +                new_page = True +                paginator.close_page() -                # state var for if the category should be added next +                # new page so print category title again                  print_cat = 1 -                new_page = True -                for details in cat_cmds: +            if print_cat: +                if new_page: +                    paginator.add_line("") +                paginator.add_line(category) +                print_cat = 0 + +            paginator.add_line(details) -                    # keep details together, paginating early if it won't fit -                    lines_adding = len(details.split('\n')) + print_cat -                    if paginator._linecount + lines_adding > self._max_lines: -                        paginator._linecount = 0 -                        new_page = True -                        paginator.close_page() +    async def _format_command(self, command: Command) -> List[str]: +        # skip if hidden and hide if session is set to +        if command.hidden and not self._show_hidden: +            return [] -                        # new page so print category title again -                        print_cat = 1 +        # Patch to make the !help command work outside of #bot-commands again +        # This probably needs a proper rewrite, but this will make it work in +        # the mean time. +        try: +            can_run = await command.can_run(self._ctx) +        except CheckFailure: +            can_run = False + +        # see if the user can run the command +        strikeout = "" +        if not can_run: +            # skip if we don't show commands they can't run +            if self._only_can_run: +                return [] +            strikeout = "~~" -                    if print_cat: -                        if new_page: -                            paginator.add_line('') -                        paginator.add_line(category) -                        print_cat = 0 +        if isinstance(self.query, commands.Command): +            prefix = "" +        else: +            prefix = constants.Client.prefix -                    paginator.add_line(details) +        signature = self._get_command_params(command) +        info = f"{strikeout}**`{prefix}{signature}`**{strikeout}" -        self._pages = paginator.pages +        # handle if the command has no docstring +        short_doc = command.short_doc or "No details provided" +        return [f"{info}\n*{short_doc}*"]      def embed_page(self, page_number: int = 0) -> Embed:          """Returns an Embed with the requested page formatted within.""" @@ -410,7 +420,7 @@ class HelpSession:          page_count = len(self._pages)          if page_count > 1: -            embed.set_footer(text=f'Page {self._current_page+1} / {page_count}') +            embed.set_footer(text=f"Page {self._current_page+1} / {page_count}")          return embed @@ -494,7 +504,7 @@ class HelpSession:  class Help(DiscordCog):      """Custom Embed Pagination Help feature.""" -    @commands.command('help') +    @commands.command("help")      async def new_help(self, ctx: Context, *commands) -> None:          """Shows Command Help."""          try: @@ -505,8 +515,8 @@ class Help(DiscordCog):              embed.title = str(error)              if error.possible_matches: -                matches = '\n'.join(error.possible_matches.keys()) -                embed.description = f'**Did you mean:**\n`{matches}`' +                matches = "\n".join(error.possible_matches.keys()) +                embed.description = f"**Did you mean:**\n`{matches}`"              await ctx.send(embed=embed) @@ -517,7 +527,7 @@ def unload(bot: Bot) -> None:      This is run if the cog raises an exception on load, or if the extension is unloaded.      """ -    bot.remove_command('help') +    bot.remove_command("help")      bot.add_command(bot._old_help) @@ -532,8 +542,8 @@ def setup(bot: Bot) -> None:      If an exception is raised during the loading of the cog, `unload` will be called in order to      reinstate the original help command.      """ -    bot._old_help = bot.get_command('help') -    bot.remove_command('help') +    bot._old_help = bot.get_command("help") +    bot.remove_command("help")      try:          bot.add_cog(Help()) diff --git a/bot/exts/evergreen/issues.py b/bot/exts/evergreen/issues.py index a0316080..b67aa4a6 100644 --- a/bot/exts/evergreen/issues.py +++ b/bot/exts/evergreen/issues.py @@ -7,6 +7,7 @@ from dataclasses import dataclass  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import (      Categories,      Channels, @@ -91,7 +92,7 @@ class IssueState:  class Issues(commands.Cog):      """Cog that allows users to retrieve issues from GitHub.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot          self.repos = [] @@ -157,13 +158,13 @@ class Issues(commands.Cog):          issue_url = json_data.get("html_url") -        return IssueState(repository, number, issue_url, json_data.get('title', ''), emoji) +        return IssueState(repository, number, issue_url, json_data.get("title", ""), emoji)      @staticmethod      def format_embed( -            results: t.List[t.Union[IssueState, FetchError]], -            user: str, -            repository: t.Optional[str] = None +        results: t.List[t.Union[IssueState, FetchError]], +        user: str, +        repository: t.Optional[str] = None      ) -> discord.Embed:          """Take a list of IssueState or FetchError and format a Discord embed for them."""          description_list = [] @@ -176,7 +177,7 @@ class Issues(commands.Cog):          resp = discord.Embed(              colour=Colours.bright_green, -            description='\n'.join(description_list) +            description="\n".join(description_list)          )          embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}" @@ -186,11 +187,11 @@ class Issues(commands.Cog):      @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)      @commands.command(aliases=("pr",))      async def issue( -            self, -            ctx: commands.Context, -            numbers: commands.Greedy[int], -            repository: str = "sir-lancebot", -            user: str = "python-discord" +        self, +        ctx: commands.Context, +        numbers: commands.Greedy[int], +        repository: str = "sir-lancebot", +        user: str = "python-discord"      ) -> None:          """Command to retrieve issue(s) from a GitHub repository."""          # Remove duplicates @@ -269,6 +270,6 @@ class Issues(commands.Cog):          await message.channel.send(embed=resp) -def setup(bot: commands.Bot) -> None: -    """Cog Retrieves Issues From Github.""" +def setup(bot: Bot) -> None: +    """Load the Issues cog."""      bot.add_cog(Issues(bot)) diff --git a/bot/exts/evergreen/latex.py b/bot/exts/evergreen/latex.py index c4a8597c..36c7e0ab 100644 --- a/bot/exts/evergreen/latex.py +++ b/bot/exts/evergreen/latex.py @@ -9,6 +9,8 @@ import discord  import matplotlib.pyplot as plt  from discord.ext import commands +from bot.bot import Bot +  # configure fonts and colors for matplotlib  plt.rcParams.update(      { @@ -89,6 +91,11 @@ class Latex(commands.Cog):              await ctx.send(file=discord.File(image, "latex.png")) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the Latex Cog.""" -    bot.add_cog(Latex(bot)) +    # As we have resource issues on this cog, +    # we have it currently disabled while we fix it. +    import logging +    logging.info("Latex cog is currently disabled. It won't be loaded.") +    return +    bot.add_cog(Latex()) diff --git a/bot/exts/evergreen/magic_8ball.py b/bot/exts/evergreen/magic_8ball.py index f974e487..28ddcea0 100644 --- a/bot/exts/evergreen/magic_8ball.py +++ b/bot/exts/evergreen/magic_8ball.py @@ -5,27 +5,26 @@ from pathlib import Path  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) +ANSWERS = json.loads(Path("bot/resources/evergreen/magic8ball.json").read_text("utf8")) +  class Magic8ball(commands.Cog):      """A Magic 8ball command to respond to a user's question.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -        with open(Path("bot/resources/evergreen/magic8ball.json"), "r", encoding="utf8") as file: -            self.answers = json.load(file) -      @commands.command(name="8ball")      async def output_answer(self, ctx: commands.Context, *, question: str) -> None:          """Return a Magic 8ball answer from answers list."""          if len(question.split()) >= 3: -            answer = random.choice(self.answers) +            answer = random.choice(ANSWERS)              await ctx.send(answer)          else:              await ctx.send("Usage: .8ball <question> (minimum length of 3 eg: `will I win?`)") -def setup(bot: commands.Bot) -> None: -    """Magic 8ball Cog load.""" -    bot.add_cog(Magic8ball(bot)) +def setup(bot: Bot) -> None: +    """Load the Magic8Ball Cog.""" +    bot.add_cog(Magic8ball()) diff --git a/bot/exts/evergreen/minesweeper.py b/bot/exts/evergreen/minesweeper.py index 3031debc..932358f9 100644 --- a/bot/exts/evergreen/minesweeper.py +++ b/bot/exts/evergreen/minesweeper.py @@ -6,7 +6,9 @@ from random import randint, random  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Client +from bot.utils.converters import CoordinateConverter  from bot.utils.exceptions import UserNotPlayingError  from bot.utils.extensions import invoke_help_command @@ -31,33 +33,6 @@ MESSAGE_MAPPING = {  log = logging.getLogger(__name__) -class CoordinateConverter(commands.Converter): -    """Converter for Coordinates.""" - -    async def convert(self, ctx: commands.Context, coordinate: str) -> typing.Tuple[int, int]: -        """Take in a coordinate string and turn it into an (x, y) tuple.""" -        if not 2 <= len(coordinate) <= 3: -            raise commands.BadArgument('Invalid co-ordinate provided') - -        coordinate = coordinate.lower() -        if coordinate[0].isalpha(): -            digit = coordinate[1:] -            letter = coordinate[0] -        else: -            digit = coordinate[:-1] -            letter = coordinate[-1] - -        if not digit.isdigit(): -            raise commands.BadArgument - -        x = ord(letter) - ord('a') -        y = int(digit) - 1 - -        if (not 0 <= x <= 9) or (not 0 <= y <= 9): -            raise commands.BadArgument -        return x, y - -  GameBoard = typing.List[typing.List[typing.Union[str, int]]] @@ -78,10 +53,10 @@ GamesDict = typing.Dict[int, Game]  class Minesweeper(commands.Cog):      """Play a game of Minesweeper.""" -    def __init__(self, bot: commands.Bot) -> None: +    def __init__(self) -> None:          self.games: GamesDict = {}  # Store the currently running games -    @commands.group(name='minesweeper', aliases=('ms',), invoke_without_command=True) +    @commands.group(name="minesweeper", aliases=("ms",), invoke_without_command=True)      async def minesweeper_group(self, ctx: commands.Context) -> None:          """Commands for Playing Minesweeper."""          await invoke_help_command(ctx) @@ -148,7 +123,7 @@ class Minesweeper(commands.Cog):                  f"Close the game with `{Client.prefix}ms end`\n"              )          except discord.errors.Forbidden: -            log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members") +            log.debug(f"{ctx.author.name} ({ctx.author.id}) has disabled DMs from server members.")              await ctx.send(f":x: {ctx.author.mention}, please enable DMs to play minesweeper.")              return @@ -158,7 +133,7 @@ class Minesweeper(commands.Cog):          dm_msg = await ctx.author.send(f"Here's your board!\n{self.format_for_discord(revealed_board)}")          if ctx.guild: -            await ctx.send(f"{ctx.author.mention} is playing Minesweeper") +            await ctx.send(f"{ctx.author.mention} is playing Minesweeper.")              chat_msg = await ctx.send(f"Here's their board!\n{self.format_for_discord(revealed_board)}")          else:              chat_msg = None @@ -237,17 +212,17 @@ class Minesweeper(commands.Cog):              return True      async def reveal_one( -            self, -            ctx: commands.Context, -            revealed: GameBoard, -            board: GameBoard, -            x: int, -            y: int +        self, +        ctx: commands.Context, +        revealed: GameBoard, +        board: GameBoard, +        x: int, +        y: int      ) -> bool:          """          Reveal one square. -        return is True if the game ended, breaking the loop in `reveal_command` and deleting the game +        return is True if the game ended, breaking the loop in `reveal_command` and deleting the game.          """          revealed[y][x] = board[y][x]          if board[y][x] == "bomb": @@ -285,13 +260,13 @@ class Minesweeper(commands.Cog):          game = self.games[ctx.author.id]          game.revealed = game.board          await self.update_boards(ctx) -        new_msg = f":no_entry: Game canceled :no_entry:\n{game.dm_msg.content}" +        new_msg = f":no_entry: Game canceled. :no_entry:\n{game.dm_msg.content}"          await game.dm_msg.edit(content=new_msg)          if game.activated_on_server:              await game.chat_msg.edit(content=new_msg)          del self.games[ctx.author.id] -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the Minesweeper cog.""" -    bot.add_cog(Minesweeper(bot)) +    bot.add_cog(Minesweeper()) diff --git a/bot/exts/evergreen/movie.py b/bot/exts/evergreen/movie.py index b3bfe998..10638aea 100644 --- a/bot/exts/evergreen/movie.py +++ b/bot/exts/evergreen/movie.py @@ -6,8 +6,9 @@ from urllib.parse import urlencode  from aiohttp import ClientSession  from discord import Embed -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot  from bot.constants import Tokens  from bot.utils.extensions import invoke_help_command  from bot.utils.pagination import ImagePaginator @@ -50,10 +51,9 @@ 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) +    @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. @@ -72,15 +72,17 @@ class Movie(Cog):          # 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) +            result = await self.get_movies_data(self.http_session, MovieGenres[genre].value, 1)          except KeyError:              await invoke_help_command(ctx)              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']}." +        if "results" not in result: +            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) @@ -88,8 +90,8 @@ class Movie(Cog):          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(): +        movies = await self.get_movies_data(self.http_session, MovieGenres[genre].value, page) +        if "results" not in movies:              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) @@ -101,12 +103,12 @@ class Movie(Cog):          await ImagePaginator.paginate(pages, ctx, embed) -    @movies.command(name='genres', aliases=['genre', 'g']) +    @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]: +    async def get_movies_data(self, client: ClientSession, genre_id: str, page: int) -> List[Dict[str, Any]]:          """Return JSON of TMDB discover request."""          # Define params of request          params = { @@ -130,7 +132,7 @@ class Movie(Cog):          pages = []          for i in range(amount): -            movie_id = movies['results'][i]['id'] +            movie_id = movies["results"][i]["id"]              movie = await self.get_movie(client, movie_id)              page, img = await self.create_page(movie) @@ -151,7 +153,7 @@ class Movie(Cog):          # Add title + tagline (if not empty)          text += f"**{movie['title']}**\n" -        if movie['tagline']: +        if movie["tagline"]:              text += f"{movie['tagline']}\n\n"          else:              text += "\n" @@ -162,8 +164,8 @@ class Movie(Cog):          text += "__**Production Information**__\n" -        companies = movie['production_companies'] -        countries = movie['production_countries'] +        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" @@ -173,8 +175,8 @@ class Movie(Cog):          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) +        if movie["runtime"] is not None: +            duration = divmod(movie["runtime"], 60)          else:              duration = ("?", "?") @@ -182,7 +184,7 @@ class Movie(Cog):          text += f"**Revenue:** ${revenue}\n"          text += f"**Duration:** {f'{duration[0]} hour(s) {duration[1]} minute(s)'}\n\n" -        text += movie['overview'] +        text += movie["overview"]          img = f"http://image.tmdb.org/t/p/w200{movie['poster_path']}" @@ -198,5 +200,5 @@ class Movie(Cog):  def setup(bot: Bot) -> None: -    """Load Movie Cog.""" +    """Load the Movie Cog."""      bot.add_cog(Movie(bot)) diff --git a/bot/exts/evergreen/ping.py b/bot/exts/evergreen/ping.py index 97f8b34d..6be78117 100644 --- a/bot/exts/evergreen/ping.py +++ b/bot/exts/evergreen/ping.py @@ -1,13 +1,17 @@ +import arrow +from dateutil.relativedelta import relativedelta  from discord import Embed  from discord.ext import commands +from bot import start_time +from bot.bot import Bot  from bot.constants import Colours  class Ping(commands.Cog): -    """Ping the bot to see its latency and state.""" +    """Get info about the bot's ping and uptime.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @commands.command(name="ping") @@ -21,7 +25,21 @@ class Ping(commands.Cog):          await ctx.send(embed=embed) +    # Originally made in 70d2170a0a6594561d59c7d080c4280f1ebcd70b by lemon & gdude2002 +    @commands.command(name="uptime") +    async def uptime(self, ctx: commands.Context) -> None: +        """Get the current uptime of the bot.""" +        difference = relativedelta(start_time - arrow.utcnow()) +        uptime_string = start_time.shift( +            seconds=-difference.seconds, +            minutes=-difference.minutes, +            hours=-difference.hours, +            days=-difference.days +        ).humanize() -def setup(bot: commands.Bot) -> None: -    """Cog load.""" +        await ctx.send(f"I started up {uptime_string}.") + + +def setup(bot: Bot) -> None: +    """Load the Ping cog."""      bot.add_cog(Ping(bot)) diff --git a/bot/exts/evergreen/pythonfacts.py b/bot/exts/evergreen/pythonfacts.py index 457c2fd3..80a8da5d 100644 --- a/bot/exts/evergreen/pythonfacts.py +++ b/bot/exts/evergreen/pythonfacts.py @@ -3,31 +3,34 @@ import itertools  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours -with open('bot/resources/evergreen/python_facts.txt') as file: +with open("bot/resources/evergreen/python_facts.txt") as file:      FACTS = itertools.cycle(list(file))  COLORS = itertools.cycle([Colours.python_blue, Colours.python_yellow]) +PYFACTS_DISCUSSION = "https://github.com/python-discord/meta/discussions/93"  class PythonFacts(commands.Cog):      """Sends a random fun fact about Python.""" -    def __init__(self, bot: commands.Bot) -> None: -        self.bot = bot - -    @commands.command(name='pythonfact', aliases=['pyfact']) +    @commands.command(name="pythonfact", aliases=("pyfact",))      async def get_python_fact(self, ctx: commands.Context) -> None:          """Sends a Random fun fact about Python.""" -        embed = discord.Embed(title='Python Facts', -                                    description=next(FACTS), -                                    colour=next(COLORS)) -        embed.add_field(name='Suggestions', -                        value="Suggest more facts [here!](https://github.com/python-discord/meta/discussions/93)") +        embed = discord.Embed( +            title="Python Facts", +            description=next(FACTS), +            colour=next(COLORS) +        ) +        embed.add_field( +            name="Suggestions", +            value=f"Suggest more facts [here!]({PYFACTS_DISCUSSION})" +        )          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Load PythonFacts Cog.""" -    bot.add_cog(PythonFacts(bot)) +def setup(bot: Bot) -> None: +    """Load the PythonFacts Cog.""" +    bot.add_cog(PythonFacts()) diff --git a/bot/exts/evergreen/recommend_game.py b/bot/exts/evergreen/recommend_game.py index 5e262a5b..35d60128 100644 --- a/bot/exts/evergreen/recommend_game.py +++ b/bot/exts/evergreen/recommend_game.py @@ -6,13 +6,14 @@ from random import shuffle  import discord  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__)  game_recs = []  # Populate the list `game_recs` with resource files  for rec_path in Path("bot/resources/evergreen/game_recs").glob("*.json"): -    with rec_path.open(encoding='utf8') as file: -        data = json.load(file) +    data = json.loads(rec_path.read_text("utf8"))      game_recs.append(data)  shuffle(game_recs) @@ -20,11 +21,11 @@ shuffle(game_recs)  class RecommendGame(commands.Cog):      """Commands related to recommending games.""" -    def __init__(self, bot: commands.Bot) -> None: +    def __init__(self, bot: Bot) -> None:          self.bot = bot          self.index = 0 -    @commands.command(name="recommendgame", aliases=['gamerec']) +    @commands.command(name="recommendgame", aliases=("gamerec",))      async def recommend_game(self, ctx: commands.Context) -> None:          """Sends an Embed of a random game recommendation."""          if self.index >= len(game_recs): @@ -33,18 +34,18 @@ class RecommendGame(commands.Cog):          game = game_recs[self.index]          self.index += 1 -        author = self.bot.get_user(int(game['author'])) +        author = self.bot.get_user(int(game["author"]))          # Creating and formatting Embed          embed = discord.Embed(color=discord.Colour.blue())          if author is not None:              embed.set_author(name=author.name, icon_url=author.avatar_url) -        embed.set_image(url=game['image']) -        embed.add_field(name='Recommendation: ' + game['title'] + '\n' + game['link'], value=game['description']) +        embed.set_image(url=game["image"]) +        embed.add_field(name=f"Recommendation: {game['title']}\n{game['link']}", value=game["description"])          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Loads the RecommendGame cog."""      bot.add_cog(RecommendGame(bot)) diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index 49127bea..e57fa2c0 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -1,128 +1,367 @@ +import asyncio  import logging  import random +import textwrap +from collections import namedtuple +from datetime import datetime, timedelta +from typing import List, Union -import discord -from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType +from aiohttp import BasicAuth, ClientError +from discord import Colour, Embed, TextChannel +from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.tasks import loop +from discord.utils import escape_markdown, sleep_until -from bot.utils.pagination import ImagePaginator +from bot.bot import Bot +from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES +from bot.utils.converters import Subreddit +from bot.utils.extensions import invoke_help_command +from bot.utils.messages import sub_clyde +from bot.utils.pagination import ImagePaginator, LinePaginator  log = logging.getLogger(__name__) +AccessToken = namedtuple("AccessToken", ["token", "expires_at"]) -class Reddit(commands.Cog): -    """Fetches reddit posts.""" -    def __init__(self, bot: commands.Bot): +class Reddit(Cog): +    """Track subreddit posts and show detailed statistics about them.""" + +    HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"} +    URL = "https://www.reddit.com" +    OAUTH_URL = "https://oauth.reddit.com" +    MAX_RETRIES = 3 + +    def __init__(self, bot: 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. +        self.webhook = None +        self.access_token = None +        self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret) -        Gets a post from r/python by default. -        Usage: -        --> .reddit [subreddit_name] [hot/top/new] -        """ +        bot.loop.create_task(self.init_reddit_ready()) +        self.auto_poster_loop.start() + +    def cog_unload(self) -> None: +        """Stop the loop task and revoke the access token when the cog is unloaded.""" +        self.auto_poster_loop.cancel() +        if self.access_token and self.access_token.expires_at > datetime.utcnow(): +            asyncio.create_task(self.revoke_access_token()) + +    async def init_reddit_ready(self) -> None: +        """Sets the reddit webhook when the cog is loaded.""" +        await self.bot.wait_until_guild_available() +        if not self.webhook: +            self.webhook = await self.bot.fetch_webhook(RedditConfig.webhook) + +    @property +    def channel(self) -> TextChannel: +        """Get the #reddit channel object from the bot's cache.""" +        return self.bot.get_channel(Channels.reddit) + +    def build_pagination_pages(self, posts: List[dict], paginate: bool) -> Union[List[tuple], str]: +        """Build embed pages required for Paginator."""          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" +        first_page = "" +        for post in posts: +            post_page = "" +            image_url = "" -        data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json') +            data = post["data"] -        try: -            posts = data["data"]["children"] -        except KeyError: -            return await ctx.send('Subreddit not found!') -        if not posts: -            return await ctx.send('No posts available!') +            title = textwrap.shorten(data["title"], width=50, placeholder="...") + +            # Normal brackets interfere with Markdown. +            title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") +            link = self.URL + data["permalink"] + +            first_page += f"**[{title.replace('*', '')}]({link})**\n" + +            text = data["selftext"] +            if text: +                first_page += textwrap.shorten(text, width=100, placeholder="...").replace("*", "") + "\n" + +            ups = data["ups"] +            comments = data["num_comments"] +            author = data["author"] + +            content_type = Emojis.reddit_post_text +            if data["is_video"] or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))): +                # This means the content type in the post is a video. +                content_type = f"{Emojis.reddit_post_video}" + +            elif data["url"].endswith(("jpg", "png", "gif")): +                # This means the content type in the post is an image. +                content_type = f"{Emojis.reddit_post_photo}" +                image_url = data["url"] -        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." +            first_page += ( +                f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}" +                f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n"              ) -        embed_titles = "" +            if paginate: +                post_page += f"**[{title}]({link})**\n\n" +                if text: +                    post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n" +                post_page += ( +                    f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002" +                    f"{comments}\u2003{Emojis.reddit_users}{author}" +                ) -        # Chooses k unique random elements from a population sequence or set. -        random_posts = random.sample(posts, k=5) +                pages.append((post_page, image_url)) -        # ----------------------------------------------------------- -        # This code below is bound of change when the emojis are added. +        if not paginate: +            # Return the first summery page if pagination is not required +            return first_page -        upvote_emoji = self.bot.get_emoji(755845219890757644) -        comment_emoji = self.bot.get_emoji(755845255001014384) -        user_emoji = self.bot.get_emoji(755845303822974997) -        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) +        pages.insert(0, (first_page, ""))  # Using image paginator, hence settings image url to empty string +        return pages -        # ------------------------------------------------------------ +    async def get_access_token(self) -> None: +        """ +        Get a Reddit API OAuth2 access token and assign it to self.access_token. -        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." +        A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog +        will be unloaded and a ClientError raised if retrieval was still unsuccessful. +        """ +        for i in range(1, self.MAX_RETRIES + 1): +            response = await self.bot.http_session.post( +                url=f"{self.URL}/api/v1/access_token", +                headers=self.HEADERS, +                auth=self.client_auth, +                data={ +                    "grant_type": "client_credentials", +                    "duration": "temporary" +                } +            ) -            # ------------------------------------------------------------------ -            # Embed building. +            if response.status == 200 and response.content_type == "application/json": +                content = await response.json() +                expiration = int(content["expires_in"]) - 60  # Subtract 1 minute for leeway. +                self.access_token = AccessToken( +                    token=content["access_token"], +                    expires_at=datetime.utcnow() + timedelta(seconds=expiration) +                ) -            embed_titles += f"**{i}.[{post_title}]({post_url})**\n" -            image_url = " " -            post_stats = f"{text_emoji}"  # Set default content type to text. +                log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}") +                return +            else: +                log.debug( +                    f"Failed to get an access token: " +                    f"status {response.status} & content type {response.content_type}; " +                    f"retrying ({i}/{self.MAX_RETRIES})" +                ) -            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} " +            await asyncio.sleep(3) -            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' +        self.bot.remove_cog(self.qualified_name) +        raise ClientError("Authentication with the Reddit API failed. Unloading the cog.") + +    async def revoke_access_token(self) -> None: +        """ +        Revoke the OAuth2 access token for the Reddit API. + +        For security reasons, it's good practice to revoke the token when it's no longer being used. +        """ +        response = await self.bot.http_session.post( +            url=f"{self.URL}/api/v1/revoke_token", +            headers=self.HEADERS, +            auth=self.client_auth, +            data={ +                "token": self.access_token.token, +                "token_type_hint": "access_token" +            } +        ) + +        if response.status in [200, 204] and response.content_type == "application/json": +            self.access_token = None +        else: +            log.warning(f"Unable to revoke access token: status {response.status}.") + +    async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]: +        """A helper method to fetch a certain amount of Reddit posts at a given route.""" +        # Reddit's JSON responses only provide 25 posts at most. +        if not 25 >= amount > 0: +            raise ValueError("Invalid amount of subreddit posts requested.") + +        # Renew the token if necessary. +        if not self.access_token or self.access_token.expires_at < datetime.utcnow(): +            await self.get_access_token() + +        url = f"{self.OAUTH_URL}/{route}" +        for _ in range(self.MAX_RETRIES): +            response = await self.bot.http_session.get( +                url=url, +                headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"}, +                params=params +            ) +            if response.status == 200 and response.content_type == 'application/json': +                # Got appropriate response - process and return. +                content = await response.json() +                posts = content["data"]["children"] + +                filtered_posts = [post for post in posts if not post["data"]["over_18"]] + +                return filtered_posts[:amount] + +            await asyncio.sleep(3) + +        log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") +        return list()  # Failed to get appropriate response within allowed number of retries. + +    async def get_top_posts( +            self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False +    ) -> Union[Embed, List[tuple]]: +        """ +        Get the top amount of posts for a given subreddit within a specified timeframe. + +        A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top +        weekly posts. + +        The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most. +        """ +        embed = Embed() + +        posts = await self.fetch_posts( +            route=f"{subreddit}/top", +            amount=amount, +            params={"t": time} +        ) +        if not posts: +            embed.title = random.choice(ERROR_REPLIES) +            embed.colour = Colour.red() +            embed.description = ( +                "Sorry! We couldn't find any SFW posts from that subreddit. " +                "If this problem persists, please let us know."              ) -            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) +            return embed + +        if paginate: +            return self.build_pagination_pages(posts, paginate=True) + +        # Use only starting summary page for #reddit channel posts. +        embed.description = self.build_pagination_pages(posts, paginate=False) +        embed.colour = Colour.blurple() +        return embed + +    @loop() +    async def auto_poster_loop(self) -> None: +        """Post the top 5 posts daily, and the top 5 posts weekly.""" +        # once d.py get support for `time` parameter in loop decorator, +        # this can be removed and the loop can use the `time=datetime.time.min` parameter +        now = datetime.utcnow() +        tomorrow = now + timedelta(days=1) +        midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0) + +        await sleep_until(midnight_tomorrow) + +        await self.bot.wait_until_guild_available() +        if not self.webhook: +            await self.bot.fetch_webhook(RedditConfig.webhook) + +        if datetime.utcnow().weekday() == 0: +            await self.top_weekly_posts() +            # if it's a monday send the top weekly posts + +        for subreddit in RedditConfig.subreddits: +            top_posts = await self.get_top_posts(subreddit=subreddit, time="day") +            username = sub_clyde(f"{subreddit} Top Daily Posts") +            message = await self.webhook.send(username=username, embed=top_posts, wait=True) + +            if message.channel.is_news(): +                await message.publish() + +    async def top_weekly_posts(self) -> None: +        """Post a summary of the top posts.""" +        for subreddit in RedditConfig.subreddits: +            # Send and pin the new weekly posts. +            top_posts = await self.get_top_posts(subreddit=subreddit, time="week") +            username = sub_clyde(f"{subreddit} Top Weekly Posts") +            message = await self.webhook.send(wait=True, username=username, embed=top_posts) -            # ------------------------------------------------------------------ +            if subreddit.lower() == "r/python": +                if not self.channel: +                    log.warning("Failed to get #reddit channel to remove pins in the weekly loop.") +                    return + +                # Remove the oldest pins so that only 12 remain at most. +                pins = await self.channel.pins() + +                while len(pins) >= 12: +                    await pins[-1].unpin() +                    del pins[-1] + +                await message.pin() + +                if message.channel.is_news(): +                    await message.publish() + +    @group(name="reddit", invoke_without_command=True) +    async def reddit_group(self, ctx: Context) -> None: +        """View the top posts from various subreddits.""" +        await invoke_help_command(ctx) + +    @reddit_group.command(name="top") +    async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: +        """Send the top posts of all time from a given subreddit.""" +        async with ctx.typing(): +            pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True) + +        await ctx.send(f"Here are the top {subreddit} posts of all time!") +        embed = Embed( +            color=Colour.blurple() +        ) -        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) +    @reddit_group.command(name="daily") +    async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: +        """Send the top posts of today from a given subreddit.""" +        async with ctx.typing(): +            pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True) + +        await ctx.send(f"Here are today's top {subreddit} posts!") +        embed = Embed( +            color=Colour.blurple() +        ) + +        await ImagePaginator.paginate(pages, ctx, embed) + +    @reddit_group.command(name="weekly") +    async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None: +        """Send the top posts of this week from a given subreddit.""" +        async with ctx.typing(): +            pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True) + +        await ctx.send(f"Here are this week's top {subreddit} posts!") +        embed = Embed( +            color=Colour.blurple() +        ) + +        await ImagePaginator.paginate(pages, ctx, embed) + +    @has_any_role(*STAFF_ROLES) +    @reddit_group.command(name="subreddits", aliases=("subs",)) +    async def subreddits_command(self, ctx: Context) -> None: +        """Send a paginated embed of all the subreddits we're relaying.""" +        embed = Embed() +        embed.title = "Relayed subreddits." +        embed.colour = Colour.blurple() + +        await LinePaginator.paginate( +            RedditConfig.subreddits, +            ctx, embed, +            footer_text="Use the reddit commands along with these to view their posts.", +            empty=False, +            max_lines=15 +        ) + -def setup(bot: commands.Bot) -> None: -    """Load the Cog.""" +def setup(bot: Bot) -> None: +    """Load the Reddit cog.""" +    if not RedditConfig.secret or not RedditConfig.client_id: +        log.error("Credentials not provided, cog not loaded.") +        return      bot.add_cog(Reddit(bot)) diff --git a/bot/exts/evergreen/snakes/__init__.py b/bot/exts/evergreen/snakes/__init__.py index bc42f0c2..7740429b 100644 --- a/bot/exts/evergreen/snakes/__init__.py +++ b/bot/exts/evergreen/snakes/__init__.py @@ -1,12 +1,11 @@  import logging -from discord.ext import commands - +from bot.bot import Bot  from bot.exts.evergreen.snakes._snakes_cog import Snakes  log = logging.getLogger(__name__) -def setup(bot: commands.Bot) -> None: -    """Snakes Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Snakes Cog."""      bot.add_cog(Snakes(bot)) diff --git a/bot/exts/evergreen/snakes/_converter.py b/bot/exts/evergreen/snakes/_converter.py index eee248cf..26bde611 100644 --- a/bot/exts/evergreen/snakes/_converter.py +++ b/bot/exts/evergreen/snakes/_converter.py @@ -24,8 +24,8 @@ class Snake(Converter):          await self.build_list()          name = name.lower() -        if name == 'python': -            return 'Python (programming language)' +        if name == "python": +            return "Python (programming language)"          def get_potential(iterable: Iterable, *, threshold: int = 80) -> List[str]:              nonlocal name @@ -47,12 +47,12 @@ class Snake(Converter):          if name.lower() in self.special_cases:              return self.special_cases.get(name.lower(), name.lower()) -        names = {snake['name']: snake['scientific'] for snake in self.snakes} +        names = {snake["name"]: snake["scientific"] for snake in self.snakes}          all_names = names.keys() | names.values()          timeout = len(all_names) * (3 / 4)          embed = discord.Embed( -            title='Found multiple choices. Please choose the correct one.', colour=0x59982F) +            title="Found multiple choices. Please choose the correct one.", colour=0x59982F)          embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url)          name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) @@ -63,14 +63,11 @@ class Snake(Converter):          """Build list of snakes from the static snake resources."""          # Get all the snakes          if cls.snakes is None: -            with (SNAKE_RESOURCES / "snake_names.json").open(encoding="utf8") as snakefile: -                cls.snakes = json.load(snakefile) - +            cls.snakes = json.loads((SNAKE_RESOURCES / "snake_names.json").read_text("utf8"))          # Get the special cases          if cls.special_cases is None: -            with (SNAKE_RESOURCES / "special_snakes.json").open(encoding="utf8") as snakefile: -                special_cases = json.load(snakefile) -            cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} +            special_cases = json.loads((SNAKE_RESOURCES / "special_snakes.json").read_text("utf8")) +            cls.special_cases = {snake["name"].lower(): snake for snake in special_cases}      @classmethod      async def random(cls) -> str: @@ -81,5 +78,5 @@ class Snake(Converter):          so I can get it from here.          """          await cls.build_list() -        names = [snake['scientific'] for snake in cls.snakes] +        names = [snake["scientific"] for snake in cls.snakes]          return random.choice(names) diff --git a/bot/exts/evergreen/snakes/_snakes_cog.py b/bot/exts/evergreen/snakes/_snakes_cog.py index 3732b559..07d3c363 100644 --- a/bot/exts/evergreen/snakes/_snakes_cog.py +++ b/bot/exts/evergreen/snakes/_snakes_cog.py @@ -9,15 +9,15 @@ import textwrap  import urllib  from functools import partial  from io import BytesIO -from typing import Any, Dict, List +from typing import Any, Dict, List, Optional -import aiohttp  import async_timeout  from PIL import Image, ImageDraw, ImageFont  from discord import Colour, Embed, File, Member, Message, Reaction  from discord.errors import HTTPException -from discord.ext.commands import Bot, Cog, CommandError, Context, bot_has_permissions, group +from discord.ext.commands import Cog, CommandError, Context, bot_has_permissions, group +from bot.bot import Bot  from bot.constants import ERROR_REPLIES, Tokens  from bot.exts.evergreen.snakes import _utils as utils  from bot.exts.evergreen.snakes._converter import Snake @@ -143,8 +143,8 @@ class Snakes(Cog):      https://github.com/python-discord/code-jam-1      """ -    wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) -    valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') +    wiki_brief = re.compile(r"(.*?)(=+ (.*?) =+)", flags=re.DOTALL) +    valid_image_extensions = ("gif", "png", "jpeg", "jpg", "webp")      def __init__(self, bot: Bot):          self.active_sal = {} @@ -183,28 +183,28 @@ class Snakes(Cog):          # Get the size of the snake icon, configure the height of the image box (yes, it changes)          icon_width = 347  # Hardcoded, not much i can do about that          icon_height = int((icon_width / snake.width) * snake.height) -        frame_copies = icon_height // CARD['frame'].height + 1 +        frame_copies = icon_height // CARD["frame"].height + 1          snake.thumbnail((icon_width, icon_height))          # Get the dimensions of the final image -        main_height = icon_height + CARD['top'].height + CARD['bottom'].height -        main_width = CARD['frame'].width +        main_height = icon_height + CARD["top"].height + CARD["bottom"].height +        main_width = CARD["frame"].width          # Start creating the foreground          foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) -        foreground.paste(CARD['top'], (0, 0)) +        foreground.paste(CARD["top"], (0, 0))          # Generate the frame borders to the correct height          for offset in range(frame_copies): -            position = (0, CARD['top'].height + offset * CARD['frame'].height) -            foreground.paste(CARD['frame'], position) +            position = (0, CARD["top"].height + offset * CARD["frame"].height) +            foreground.paste(CARD["frame"], position)          # Add the image and bottom part of the image -        foreground.paste(snake, (36, CARD['top'].height))  # Also hardcoded :( -        foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height)) +        foreground.paste(snake, (36, CARD["top"].height))  # Also hardcoded :( +        foreground.paste(CARD["bottom"], (0, CARD["top"].height + icon_height))          # Setup the background -        back = random.choice(CARD['backs']) +        back = random.choice(CARD["backs"])          back_copies = main_height // back.height + 1          full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) @@ -216,11 +216,11 @@ class Snakes(Cog):          full_image.paste(foreground, (0, 0), foreground)          # Get the first two sentences of the info -        description = '.'.join(content['info'].split(".")[:2]) + '.' +        description = ".".join(content["info"].split(".")[:2]) + "."          # Setup positioning variables          margin = 36 -        offset = CARD['top'].height + icon_height + margin +        offset = CARD["top"].height + icon_height + margin          # Create blank rectangle image which will be behind the text          rectangle = Image.new( @@ -242,12 +242,12 @@ class Snakes(Cog):          # Draw the text onto the final image          draw = ImageDraw.Draw(full_image)          for line in textwrap.wrap(description, 36): -            draw.text([margin + 4, offset], line, font=CARD['font']) -            offset += CARD['font'].getsize(line)[1] +            draw.text([margin + 4, offset], line, font=CARD["font"]) +            offset += CARD["font"].getsize(line)[1]          # Get the image contents as a BufferIO object          buffer = BytesIO() -        full_image.save(buffer, 'PNG') +        full_image.save(buffer, "PNG")          buffer.seek(0)          return buffer @@ -275,13 +275,13 @@ class Snakes(Cog):          return message -    async def _fetch(self, session: aiohttp.ClientSession, url: str, params: dict = None) -> dict: +    async def _fetch(self, url: str, params: Optional[dict] = None) -> dict:          """Asynchronous web request helper method."""          if params is None:              params = {}          async with async_timeout.timeout(10): -            async with session.get(url, params=params) as response: +            async with self.bot.http_session.get(url, params=params) as response:                  return await response.json()      def _get_random_long_message(self, messages: List[str], retries: int = 10) -> str: @@ -309,96 +309,95 @@ class Snakes(Cog):          """          snake_info = {} -        async with aiohttp.ClientSession() as session: -            params = { -                'format': 'json', -                'action': 'query', -                'list': 'search', -                'srsearch': name, -                'utf8': '', -                'srlimit': '1', -            } - -            json = await self._fetch(session, URL, params=params) - -            # Wikipedia does have a error page -            try: -                pageid = json["query"]["search"][0]["pageid"] -            except KeyError: -                # Wikipedia error page ID(?) -                pageid = 41118 -            except IndexError: -                return None - -            params = { -                'format': 'json', -                'action': 'query', -                'prop': 'extracts|images|info', -                'exlimit': 'max', -                'explaintext': '', -                'inprop': 'url', -                'pageids': pageid -            } +        params = { +            "format": "json", +            "action": "query", +            "list": "search", +            "srsearch": name, +            "utf8": "", +            "srlimit": "1", +        } -            json = await self._fetch(session, URL, params=params) +        json = await self._fetch(URL, params=params) -            # Constructing dict - handle exceptions later -            try: -                snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] -                snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] -                snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] -                snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] -                snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] -            except KeyError: -                snake_info["error"] = True - -            if snake_info["images"]: -                i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/' -                image_list = [] -                map_list = [] -                thumb_list = [] - -                # Wikipedia has arbitrary images that are not snakes -                banned = [ -                    'Commons-logo.svg', -                    'Red%20Pencil%20Icon.png', -                    'distribution', -                    'The%20Death%20of%20Cleopatra%20arthur.jpg', -                    'Head%20of%20holotype', -                    'locator', -                    'Woma.png', -                    '-map.', -                    '.svg', -                    'ange.', -                    'Adder%20(PSF).png' -                ] - -                for image in snake_info["images"]: -                    # Images come in the format of `File:filename.extension` -                    file, sep, filename = image["title"].partition(':') -                    filename = filename.replace(" ", "%20")  # Wikipedia returns good data! - -                    if not filename.startswith('Map'): -                        if any(ban in filename for ban in banned): -                            pass -                        else: -                            image_list.append(f"{i_url}{filename}") -                            thumb_list.append(f"{i_url}{filename}?width=100") +        # Wikipedia does have a error page +        try: +            pageid = json["query"]["search"][0]["pageid"] +        except KeyError: +            # Wikipedia error page ID(?) +            pageid = 41118 +        except IndexError: +            return None + +        params = { +            "format": "json", +            "action": "query", +            "prop": "extracts|images|info", +            "exlimit": "max", +            "explaintext": "", +            "inprop": "url", +            "pageids": pageid +        } + +        json = await self._fetch(URL, params=params) + +        # Constructing dict - handle exceptions later +        try: +            snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] +            snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] +            snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] +            snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] +            snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] +        except KeyError: +            snake_info["error"] = True + +        if snake_info["images"]: +            i_url = "https://commons.wikimedia.org/wiki/Special:FilePath/" +            image_list = [] +            map_list = [] +            thumb_list = [] + +            # Wikipedia has arbitrary images that are not snakes +            banned = [ +                "Commons-logo.svg", +                "Red%20Pencil%20Icon.png", +                "distribution", +                "The%20Death%20of%20Cleopatra%20arthur.jpg", +                "Head%20of%20holotype", +                "locator", +                "Woma.png", +                "-map.", +                ".svg", +                "ange.", +                "Adder%20(PSF).png" +            ] + +            for image in snake_info["images"]: +                # Images come in the format of `File:filename.extension` +                file, sep, filename = image["title"].partition(":") +                filename = filename.replace(" ", "%20")  # Wikipedia returns good data! + +                if not filename.startswith("Map"): +                    if any(ban in filename for ban in banned): +                        pass                      else: -                        map_list.append(f"{i_url}{filename}") +                        image_list.append(f"{i_url}{filename}") +                        thumb_list.append(f"{i_url}{filename}?width=100") +                else: +                    map_list.append(f"{i_url}{filename}") -            snake_info["image_list"] = image_list -            snake_info["map_list"] = map_list -            snake_info["thumb_list"] = thumb_list -            snake_info["name"] = name +        snake_info["image_list"] = image_list +        snake_info["map_list"] = map_list +        snake_info["thumb_list"] = thumb_list +        snake_info["name"] = name -            match = self.wiki_brief.match(snake_info['extract']) -            info = match.group(1) if match else None +        match = self.wiki_brief.match(snake_info["extract"]) +        info = match.group(1) if match else None -            if info: -                info = info.replace("\n", "\n\n")  # Give us some proper paragraphs. +        if info: +            info = info.replace("\n", "\n\n")  # Give us some proper paragraphs. -            snake_info["info"] = info +        snake_info["info"] = info          return snake_info @@ -423,7 +422,7 @@ class Snakes(Cog):          try:              reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate)          except asyncio.TimeoutError: -            await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.") +            await ctx.send(f"You took too long. The correct answer was **{options[answer]}**.")              await message.clear_reactions()              return @@ -438,13 +437,13 @@ class Snakes(Cog):      # endregion      # region: Commands -    @group(name='snakes', aliases=('snake',), invoke_without_command=True) +    @group(name="snakes", aliases=("snake",), invoke_without_command=True)      async def snakes_group(self, ctx: Context) -> None:          """Commands from our first code jam."""          await invoke_help_command(ctx)      @bot_has_permissions(manage_messages=True) -    @snakes_group.command(name='antidote') +    @snakes_group.command(name="antidote")      @locked()      async def antidote_command(self, ctx: Context) -> None:          """ @@ -498,9 +497,11 @@ class Snakes(Cog):          for i in range(0, 10):              page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}")              page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}") -            board.append(f"`{i+1:02d}` " -                         f"{page_guess_list[i]} - " -                         f"{page_result_list[i]}") +            board.append( +                f"`{i+1:02d}` " +                f"{page_guess_list[i]} - " +                f"{page_result_list[i]}" +            )              board.append(EMPTY_UNICODE)          antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board))          board_id = await ctx.send(embed=antidote_embed)  # Display board @@ -578,15 +579,19 @@ class Snakes(Cog):              antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote")              antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url)              antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif") -            antidote_embed.add_field(name=EMPTY_UNICODE, -                                     value=f"Sorry you didnt make the antidote in time.\n" -                                           f"The formula was {' '.join(antidote_answer)}") +            antidote_embed.add_field( +                name=EMPTY_UNICODE, +                value=( +                    f"Sorry you didnt make the antidote in time.\n" +                    f"The formula was {' '.join(antidote_answer)}" +                ) +            )              await board_id.edit(embed=antidote_embed)          log.debug("Ending pagination and removing all reactions...")          await board_id.clear_reactions() -    @snakes_group.command(name='draw') +    @snakes_group.command(name="draw")      async def draw_command(self, ctx: Context) -> None:          """          Draws a random snek using Perlin noise. @@ -621,10 +626,10 @@ class Snakes(Cog):                  bg_color=bg_color              )              png_bytes = utils.frame_to_png_bytes(image_frame) -            file = File(png_bytes, filename='snek.png') +            file = File(png_bytes, filename="snek.png")              await ctx.send(file=file) -    @snakes_group.command(name='get') +    @snakes_group.command(name="get")      @bot_has_permissions(manage_messages=True)      @locked()      async def get_command(self, ctx: Context, *, name: Snake = None) -> None: @@ -642,8 +647,9 @@ class Snakes(Cog):              else:                  data = await self._get_snek(name) -            if data.get('error'): -                return await ctx.send('Could not fetch data from Wikipedia.') +            if data.get("error"): +                await ctx.send("Could not fetch data from Wikipedia.") +                return              description = data["info"] @@ -661,19 +667,25 @@ class Snakes(Cog):              # Build and send the embed.              embed = Embed( -                title=data.get("title", data.get('name')), +                title=data.get("title", data.get("name")),                  description=description,                  colour=0x59982F,              ) -            emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png' -            image = next((url for url in data['image_list'] -                          if url.endswith(self.valid_image_extensions)), emoji) +            emoji = "https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png" + +            _iter = ( +                url +                for url in data["image_list"] +                if url.endswith(self.valid_image_extensions) +            ) +            image = next(_iter, emoji) +              embed.set_image(url=image)              await ctx.send(embed=embed) -    @snakes_group.command(name='guess', aliases=('identify',)) +    @snakes_group.command(name="guess", aliases=("identify",))      @locked()      async def guess_command(self, ctx: Context) -> None:          """ @@ -693,11 +705,15 @@ class Snakes(Cog):                  data = await self._get_snek(snake) -                image = next((url for url in data['image_list'] -                              if url.endswith(self.valid_image_extensions)), None) +                _iter = ( +                    url +                    for url in data["image_list"] +                    if url.endswith(self.valid_image_extensions) +                ) +                image = next(_iter, None)              embed = Embed( -                title='Which of the following is the snake in the image?', +                title="Which of the following is the snake in the image?",                  description="\n".join(                      f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes),                  colour=SNAKE_COLOR @@ -708,7 +724,7 @@ class Snakes(Cog):          options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes}          await self._validate_answer(ctx, guess, answer, options) -    @snakes_group.command(name='hatch') +    @snakes_group.command(name="hatch")      async def hatch_command(self, ctx: Context) -> None:          """          Hatches your personal snake. @@ -720,7 +736,7 @@ class Snakes(Cog):          snake_image = utils.snakes[snake_name]          # Hatch the snake -        message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:...")) +        message = await ctx.send(embed=Embed(description="Hatching your snake :snake:..."))          await asyncio.sleep(1)          for stage in utils.stages: @@ -734,12 +750,12 @@ class Snakes(Cog):          my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name))          my_snake_embed.set_thumbnail(url=snake_image)          my_snake_embed.set_footer( -            text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator) +            text=" Owner: {0}#{1}".format(ctx.author.name, ctx.author.discriminator)          ) -        await ctx.channel.send(embed=my_snake_embed) +        await ctx.send(embed=my_snake_embed) -    @snakes_group.command(name='movie') +    @snakes_group.command(name="movie")      async def movie_command(self, ctx: Context) -> None:          """          Gets a random snake-related movie from TMDB. @@ -800,12 +816,12 @@ class Snakes(Cog):          embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")          try: -            await ctx.channel.send(embed=embed) +            await ctx.send(embed=embed)          except HTTPException as err: -            await ctx.channel.send("An error occurred while fetching a snake-related movie!") +            await ctx.send("An error occurred while fetching a snake-related movie!")              raise err from None -    @snakes_group.command(name='quiz') +    @snakes_group.command(name="quiz")      @locked()      async def quiz_command(self, ctx: Context) -> None:          """ @@ -828,10 +844,10 @@ class Snakes(Cog):              )          ) -        quiz = await ctx.channel.send("", embed=embed) +        quiz = await ctx.send(embed=embed)          await self._validate_answer(ctx, quiz, answer, options) -    @snakes_group.command(name='name', aliases=('name_gen',)) +    @snakes_group.command(name="name", aliases=("name_gen",))      async def name_command(self, ctx: Context, *, name: str = None) -> None:          """          Snakifies a username. @@ -855,7 +871,7 @@ class Snakes(Cog):          This was written by Iceman, and modified for inclusion into the bot by lemon.          """          snake_name = await self._get_snake_name() -        snake_name = snake_name['name'] +        snake_name = snake_name["name"]          snake_prefix = ""          # Set aside every word in the snake name except the last. @@ -900,9 +916,10 @@ class Snakes(Cog):              color=SNAKE_COLOR          ) -        return await ctx.send(embed=embed) +        await ctx.send(embed=embed) +        return -    @snakes_group.command(name='sal') +    @snakes_group.command(name="sal")      @locked()      async def sal_command(self, ctx: Context) -> None:          """ @@ -921,7 +938,7 @@ class Snakes(Cog):          await game.open_game() -    @snakes_group.command(name='about') +    @snakes_group.command(name="about")      async def about_command(self, ctx: Context) -> None:          """Show an embed with information about the event, its participants, and its winners."""          contributors = [ @@ -964,9 +981,9 @@ class Snakes(Cog):              )          ) -        await ctx.channel.send(embed=embed) +        await ctx.send(embed=embed) -    @snakes_group.command(name='card') +    @snakes_group.command(name="card")      async def card_command(self, ctx: Context, *, name: Snake = None) -> None:          """          Create an interesting little card from a snake. @@ -976,7 +993,7 @@ class Snakes(Cog):          # Get the snake data we need          if not name:              name_obj = await self._get_snake_name() -            name = name_obj['scientific'] +            name = name_obj["scientific"]              content = await self._get_snek(name)          elif isinstance(name, dict): @@ -990,7 +1007,7 @@ class Snakes(Cog):              stream = BytesIO()              async with async_timeout.timeout(10): -                async with self.bot.http_session.get(content['image_list'][0]) as response: +                async with self.bot.http_session.get(content["image_list"][0]) as response:                      stream.write(await response.read())              stream.seek(0) @@ -1001,10 +1018,10 @@ class Snakes(Cog):          # Send it!          await ctx.send(              f"A wild {content['name'].title()} appears!", -            file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png") +            file=File(final_buffer, filename=content["name"].replace(" ", "") + ".png")          ) -    @snakes_group.command(name='fact') +    @snakes_group.command(name="fact")      async def fact_command(self, ctx: Context) -> None:          """          Gets a snake-related fact. @@ -1018,9 +1035,9 @@ class Snakes(Cog):              color=SNAKE_COLOR,              description=question          ) -        await ctx.channel.send(embed=embed) +        await ctx.send(embed=embed) -    @snakes_group.command(name='snakify') +    @snakes_group.command(name="snakify")      async def snakify_command(self, ctx: Context, *, message: str = None) -> None:          """          How would I talk if I were a snake? @@ -1033,14 +1050,14 @@ class Snakes(Cog):          """          with ctx.typing():              embed = Embed() -            user = ctx.message.author +            user = ctx.author              if not message:                  # Get a random message from the users history                  messages = [] -                async for message in ctx.channel.history(limit=500).filter( -                        lambda msg: msg.author == ctx.message.author  # Message was sent by author. +                async for message in ctx.history(limit=500).filter( +                        lambda msg: msg.author == ctx.author  # Message was sent by author.                  ):                      messages.append(message.content) @@ -1059,9 +1076,9 @@ class Snakes(Cog):              )              embed.description = f"*{self._snakify(message)}*" -            await ctx.channel.send(embed=embed) +            await ctx.send(embed=embed) -    @snakes_group.command(name='video', aliases=('get_video',)) +    @snakes_group.command(name="video", aliases=("get_video",))      async def video_command(self, ctx: Context, *, search: str = None) -> None:          """          Gets a YouTube video about snakes. @@ -1072,13 +1089,13 @@ class Snakes(Cog):          """          # Are we searching for anything specific?          if search: -            query = search + ' snake' +            query = search + " snake"          else:              snake = await self._get_snake_name() -            query = snake['name'] +            query = snake["name"]          # Build the URL and make the request -        url = 'https://www.googleapis.com/youtube/v3/search' +        url = "https://www.googleapis.com/youtube/v3/search"          response = await self.bot.http_session.get(              url,              params={ @@ -1094,14 +1111,14 @@ class Snakes(Cog):          # Send the user a video          if len(data) > 0:              num = random.randint(0, len(data) - 1) -            youtube_base_url = 'https://www.youtube.com/watch?v=' -            await ctx.channel.send( +            youtube_base_url = "https://www.youtube.com/watch?v=" +            await ctx.send(                  content=f"{youtube_base_url}{data[num]['id']['videoId']}"              )          else:              log.warning(f"YouTube API error. Full response looks like {response}") -    @snakes_group.command(name='zen') +    @snakes_group.command(name="zen")      async def zen_command(self, ctx: Context) -> None:          """          Gets a random quote from the Zen of Python, except as if spoken by a snake. @@ -1120,7 +1137,7 @@ class Snakes(Cog):          # Embed and send          embed.description = zen_quote -        await ctx.channel.send( +        await ctx.send(              embed=embed          )      # endregion diff --git a/bot/exts/evergreen/snakes/_utils.py b/bot/exts/evergreen/snakes/_utils.py index 7d6caf04..0a5894b7 100644 --- a/bot/exts/evergreen/snakes/_utils.py +++ b/bot/exts/evergreen/snakes/_utils.py @@ -17,38 +17,38 @@ from bot.constants import Roles  SNAKE_RESOURCES = Path("bot/resources/snakes").absolute() -h1 = r'''``` +h1 = r"""```          ----         ------       /--------\       |--------|       |--------|        \------/ -        ----```''' -h2 = r'''``` +        ----```""" +h2 = r"""```          ----         ------       /---\-/--\       |-----\--|       |--------|        \------/ -        ----```''' -h3 = r'''``` +        ----```""" +h3 = r"""```          ----         ------       /---\-/--\       |-----\--|       |-----/--|        \----\-/ -        ----```''' -h4 = r'''``` +        ----```""" +h4 = r"""```          -----         -----  \       /--|  /---\       |--\  -\---|       |--\--/--  /        \------- / -        ------```''' +        ------```"""  stages = [h1, h2, h3, h4]  snakes = {      "Baby Python": "https://i.imgur.com/SYOcmSa.png", @@ -114,8 +114,7 @@ ANGLE_RANGE = math.pi * 2  def get_resource(file: str) -> List[dict]:      """Load Snake resources JSON.""" -    with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: -        return json.load(snakefile) +    return json.loads((SNAKE_RESOURCES / f"{file}.json").read_text("utf-8"))  def smoothstep(t: float) -> float: @@ -191,8 +190,9 @@ class PerlinNoiseFactory(object):      def get_plain_noise(self, *point) -> float:          """Get plain noise for a single point, without taking into account either octaves or tiling."""          if len(point) != self.dimension: -            raise ValueError("Expected {0} values, got {1}".format( -                self.dimension, len(point))) +            raise ValueError( +                f"Expected {self.dimension} values, got {len(point)}" +            )          # Build a list of the (min, max) bounds in each dimension          grid_coords = [] @@ -321,7 +321,7 @@ def create_snek_frame(          image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y])      ) -    image = Image.new(mode='RGB', size=image_dimensions, color=bg_color) +    image = Image.new(mode="RGB", size=image_dimensions, color=bg_color)      draw = ImageDraw(image)      for index in range(1, len(points)):          point = points[index] @@ -345,7 +345,7 @@ def create_snek_frame(  def frame_to_png_bytes(image: Image) -> io.BytesIO:      """Convert image to byte stream."""      stream = io.BytesIO() -    image.save(stream, format='PNG') +    image.save(stream, format="PNG")      stream.seek(0)      return stream @@ -373,7 +373,7 @@ class SnakeAndLaddersGame:          self.snakes = snakes          self.ctx = context          self.channel = self.ctx.channel -        self.state = 'booting' +        self.state = "booting"          self.started = False          self.author = self.ctx.author          self.players = [] @@ -413,7 +413,7 @@ class SnakeAndLaddersGame:              "**Snakes and Ladders**: A new game is about to start!",              file=File(                  str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"), -                filename='Snakes and Ladders.jpg' +                filename="Snakes and Ladders.jpg"              )          )          startup = await self.channel.send( @@ -423,7 +423,7 @@ class SnakeAndLaddersGame:          for emoji in STARTUP_SCREEN_EMOJI:              await startup.add_reaction(emoji) -        self.state = 'waiting' +        self.state = "waiting"          while not self.started:              try: @@ -460,7 +460,7 @@ class SnakeAndLaddersGame:          self.players.append(user)          self.player_tiles[user.id] = 1 -        avatar_bytes = await user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE).read() +        avatar_bytes = await user.avatar_url_as(format="jpeg", size=PLAYER_ICON_IMAGE_SIZE).read()          im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE))          self.avatar_images[user.id] = im @@ -475,7 +475,7 @@ class SnakeAndLaddersGame:              if user == p:                  await self.channel.send(user.mention + " You are already in the game.", delete_after=10)                  return -        if self.state != 'waiting': +        if self.state != "waiting":              await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10)              return          if len(self.players) is MAX_PLAYERS: @@ -510,7 +510,7 @@ class SnakeAndLaddersGame:                      delete_after=10                  ) -                if self.state != 'waiting' and len(self.players) == 0: +                if self.state != "waiting" and len(self.players) == 0:                      await self.channel.send("**Snakes and Ladders**: The game has been surrendered!")                      is_surrendered = True                      self._destruct() @@ -535,12 +535,12 @@ class SnakeAndLaddersGame:              await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10)              return -        if not self.state == 'waiting': +        if not self.state == "waiting":              await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10)              return -        self.state = 'starting' -        player_list = ', '.join(user.mention for user in self.players) +        self.state = "starting" +        player_list = ", ".join(user.mention for user in self.players)          await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list)          await self.start_round() @@ -556,10 +556,10 @@ class SnakeAndLaddersGame:                  ))              ) -        self.state = 'roll' +        self.state = "roll"          for user in self.players:              self.round_has_rolled[user.id] = False -        board_img = Image.open(str(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg")) +        board_img = Image.open(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg")          player_row_size = math.ceil(MAX_PLAYERS / 2)          for i, player in enumerate(self.players): @@ -574,8 +574,8 @@ class SnakeAndLaddersGame:              board_img.paste(self.avatar_images[player.id],                              box=(x_offset, y_offset)) -        board_file = File(frame_to_png_bytes(board_img), filename='Board.jpg') -        player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) +        board_file = File(frame_to_png_bytes(board_img), filename="Board.jpg") +        player_list = "\n".join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players)          # Store and send new messages          temp_board = await self.channel.send( @@ -644,7 +644,7 @@ class SnakeAndLaddersGame:          if user.id not in self.player_tiles:              await self.channel.send(user.mention + " You are not in the match.", delete_after=10)              return -        if self.state != 'roll': +        if self.state != "roll":              await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10)              return          if self.round_has_rolled[user.id]: @@ -673,7 +673,7 @@ class SnakeAndLaddersGame:      async def _complete_round(self) -> None:          """At the conclusion of a round check to see if there's been a winner.""" -        self.state = 'post_round' +        self.state = "post_round"          # check for winner          winner = self._check_winner() @@ -688,7 +688,7 @@ class SnakeAndLaddersGame:      def _check_winner(self) -> Member:          """Return a winning member if we're in the post-round state and there's a winner.""" -        if self.state != 'post_round': +        if self.state != "post_round":              return None          return next((player for player in self.players if self.player_tiles[player.id] == 100),                      None) diff --git a/bot/exts/evergreen/source.py b/bot/exts/evergreen/source.py index 45752bf9..8fb72143 100644 --- a/bot/exts/evergreen/source.py +++ b/bot/exts/evergreen/source.py @@ -1,39 +1,18 @@  import inspect  from pathlib import Path -from typing import Optional, Tuple, Union +from typing import Optional, Tuple  from discord import Embed  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Source - -SourceType = Union[commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] - - -class SourceConverter(commands.Converter): -    """Convert an argument into a help command, tag, command, or cog.""" - -    async def convert(self, ctx: commands.Context, argument: str) -> SourceType: -        """Convert argument into source object.""" -        cog = ctx.bot.get_cog(argument) -        if cog: -            return cog - -        cmd = ctx.bot.get_command(argument) -        if cmd: -            return cmd - -        raise commands.BadArgument( -            f"Unable to convert `{argument}` to valid command or Cog." -        ) +from bot.utils.converters import SourceConverter, SourceType  class BotSource(commands.Cog):      """Displays information about the bot's source code.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @commands.command(name="source", aliases=("src",))      async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None:          """Display information and a GitHub link to the source code of a command, tag, or cog.""" @@ -85,7 +64,7 @@ class BotSource(commands.Cog):          url, location, first_line = self.get_source_link(source_object)          if isinstance(source_object, commands.Command): -            if source_object.cog_name == 'Help': +            if source_object.cog_name == "Help":                  title = "Help Command"                  description = source_object.__doc__.splitlines()[1]              else: @@ -104,6 +83,6 @@ class BotSource(commands.Cog):          return embed -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the BotSource cog.""" -    bot.add_cog(BotSource(bot)) +    bot.add_cog(BotSource()) diff --git a/bot/exts/evergreen/space.py b/bot/exts/evergreen/space.py index 323ff659..5e87c6d5 100644 --- a/bot/exts/evergreen/space.py +++ b/bot/exts/evergreen/space.py @@ -1,15 +1,16 @@  import logging  import random  from datetime import date, datetime -from typing import Any, Dict, Optional, Union +from typing import Any, Dict, Optional  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 discord.ext.commands import Cog, Context, group  from bot.bot import Bot  from bot.constants import Tokens +from bot.utils.converters import DateConverter  from bot.utils.extensions import invoke_help_command  logger = logging.getLogger(__name__) @@ -21,25 +22,10 @@ NASA_EPIC_BASE_URL = "https://epic.gsfc.nasa.gov"  APOD_MIN_DATE = date(1995, 6, 16) -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: Bot): -        self.bot = bot          self.http_session = bot.http_session          self.rovers = {} @@ -67,7 +53,7 @@ class Space(Cog):          await invoke_help_command(ctx)      @space.command(name="apod") -    async def apod(self, ctx: Context, date: Optional[str] = None) -> None: +    async def apod(self, ctx: Context, date: Optional[str]) -> None:          """          Get Astronomy Picture of Day from NASA API. Date is optional parameter, what formatting is YYYY-MM-DD. @@ -100,7 +86,7 @@ class Space(Cog):          )      @space.command(name="nasa") -    async def nasa(self, ctx: Context, *, search_term: Optional[str] = None) -> None: +    async def nasa(self, ctx: Context, *, search_term: Optional[str]) -> None:          """Get random NASA information/facts + image. Support `search_term` parameter for more specific search."""          params = {              "media_type": "image" @@ -125,8 +111,8 @@ class Space(Cog):          )      @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.""" +    async def epic(self, ctx: Context, date: Optional[str]) -> None: +        """Get a random image of the Earth from the NASA EPIC API. Support date parameter, format is YYYY-MM-DD."""          if date:              try:                  show_date = datetime.strptime(date, "%Y-%m-%d").date().isoformat() @@ -161,8 +147,8 @@ class Space(Cog):      async def mars(          self,          ctx: Context, -        date: Optional[DateConverter] = None, -        rover: Optional[str] = "curiosity" +        date: Optional[DateConverter], +        rover: str = "curiosity"      ) -> None:          """          Get random Mars image by date. Support both SOL (martian solar day) and earth date and rovers. @@ -207,7 +193,7 @@ class Space(Cog):              )          ) -    @mars.command(name="dates", aliases=["d", "date", "rover", "rovers", "r"]) +    @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( @@ -242,7 +228,7 @@ class Space(Cog):  def setup(bot: Bot) -> None: -    """Load Space Cog.""" +    """Load the Space cog."""      if not Tokens.nasa:          logger.warning("Can't find NASA API key. Not loading Space Cog.")          return diff --git a/bot/exts/evergreen/speedrun.py b/bot/exts/evergreen/speedrun.py index 21aad5aa..774eff81 100644 --- a/bot/exts/evergreen/speedrun.py +++ b/bot/exts/evergreen/speedrun.py @@ -5,23 +5,22 @@ from random import choice  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) -with Path('bot/resources/evergreen/speedrun_links.json').open(encoding="utf8") as file: -    LINKS = json.load(file) + +LINKS = json.loads(Path("bot/resources/evergreen/speedrun_links.json").read_text("utf8"))  class Speedrun(commands.Cog):      """Commands about the video game speedrunning community.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @commands.command(name="speedrun")      async def get_speedrun(self, ctx: commands.Context) -> None:          """Sends a link to a video of a random speedrun."""          await ctx.send(choice(LINKS)) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the Speedrun cog.""" -    bot.add_cog(Speedrun(bot)) +    bot.add_cog(Speedrun()) diff --git a/bot/exts/evergreen/status_codes.py b/bot/exts/evergreen/status_codes.py index 7c00fe20..a866692e 100644 --- a/bot/exts/evergreen/status_codes.py +++ b/bot/exts/evergreen/status_codes.py @@ -3,6 +3,7 @@ from http import HTTPStatus  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.utils.extensions import invoke_help_command  HTTP_DOG_URL = "https://httpstatusdogs.com/img/{code}.jpg" @@ -12,7 +13,7 @@ HTTP_CAT_URL = "https://http.cat/{code}.jpg"  class HTTPStatusCodes(commands.Cog):      """Commands that give HTTP statuses described and visualized by cats and dogs.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @commands.group(name="http_status", aliases=("status", "httpstatus")) @@ -21,10 +22,10 @@ class HTTPStatusCodes(commands.Cog):          if not ctx.invoked_subcommand:              await invoke_help_command(ctx) -    @http_status_group.command(name='cat') +    @http_status_group.command(name="cat")      async def http_cat(self, ctx: commands.Context, code: int) -> None:          """Sends an embed with an image of a cat, portraying the status code.""" -        embed = discord.Embed(title=f'**Status: {code}**') +        embed = discord.Embed(title=f"**Status: {code}**")          url = HTTP_CAT_URL.format(code=code)          try: @@ -36,18 +37,18 @@ class HTTPStatusCodes(commands.Cog):                      raise NotImplementedError          except ValueError: -            embed.set_footer(text='Inputted status code does not exist.') +            embed.set_footer(text="Inputted status code does not exist.")          except NotImplementedError: -            embed.set_footer(text='Inputted status code is not implemented by http.cat yet.') +            embed.set_footer(text="Inputted status code is not implemented by http.cat yet.")          finally:              await ctx.send(embed=embed) -    @http_status_group.command(name='dog') +    @http_status_group.command(name="dog")      async def http_dog(self, ctx: commands.Context, code: int) -> None:          """Sends an embed with an image of a dog, portraying the status code.""" -        embed = discord.Embed(title=f'**Status: {code}**') +        embed = discord.Embed(title=f"**Status: {code}**")          url = HTTP_DOG_URL.format(code=code)          try: @@ -59,15 +60,15 @@ class HTTPStatusCodes(commands.Cog):                      raise NotImplementedError          except ValueError: -            embed.set_footer(text='Inputted status code does not exist.') +            embed.set_footer(text="Inputted status code does not exist.")          except NotImplementedError: -            embed.set_footer(text='Inputted status code is not implemented by httpstatusdogs.com yet.') +            embed.set_footer(text="Inputted status code is not implemented by httpstatusdogs.com yet.")          finally:              await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the HTTPStatusCodes cog."""      bot.add_cog(HTTPStatusCodes(bot)) diff --git a/bot/exts/evergreen/tic_tac_toe.py b/bot/exts/evergreen/tic_tac_toe.py index 6e21528e..bd5e0102 100644 --- a/bot/exts/evergreen/tic_tac_toe.py +++ b/bot/exts/evergreen/tic_tac_toe.py @@ -58,7 +58,7 @@ class Player:              )          try: -            react, _ = await self.ctx.bot.wait_for('reaction_add', timeout=30.0, check=check_for_move) +            react, _ = await self.ctx.bot.wait_for("reaction_add", timeout=30.0, check=check_for_move)          except asyncio.TimeoutError:              return True, None          else: @@ -246,8 +246,7 @@ def is_requester_free() -> t.Callable:  class TicTacToe(Cog):      """TicTacToe cog contains tic-tac-toe game commands.""" -    def __init__(self, bot: Bot): -        self.bot = bot +    def __init__(self):          self.games: t.List[Game] = []      @guild_only() @@ -323,5 +322,5 @@ class TicTacToe(Cog):  def setup(bot: Bot) -> None: -    """Load TicTacToe Cog.""" -    bot.add_cog(TicTacToe(bot)) +    """Load the TicTacToe cog.""" +    bot.add_cog(TicTacToe()) diff --git a/bot/exts/evergreen/timed.py b/bot/exts/evergreen/timed.py index 5f177fd6..2ea6b419 100644 --- a/bot/exts/evergreen/timed.py +++ b/bot/exts/evergreen/timed.py @@ -4,6 +4,8 @@ from time import perf_counter  from discord import Message  from discord.ext import commands +from bot.bot import Bot +  class TimedCommands(commands.Cog):      """Time the command execution of a command.""" @@ -16,7 +18,7 @@ class TimedCommands(commands.Cog):          return await ctx.bot.get_context(msg) -    @commands.command(name="timed", aliases=["time", "t"]) +    @commands.command(name="timed", aliases=("time", "t"))      async def timed(self, ctx: commands.Context, *, command: str) -> None:          """Time the command execution of a command."""          new_ctx = await self.create_execution_context(ctx, command) @@ -41,6 +43,6 @@ class TimedCommands(commands.Cog):          await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.") -def setup(bot: commands.Bot) -> None: -    """Cog load.""" -    bot.add_cog(TimedCommands(bot)) +def setup(bot: Bot) -> None: +    """Load the Timed cog.""" +    bot.add_cog(TimedCommands()) diff --git a/bot/exts/evergreen/trivia_quiz.py b/bot/exts/evergreen/trivia_quiz.py index fe692c2a..352d5ae8 100644 --- a/bot/exts/evergreen/trivia_quiz.py +++ b/bot/exts/evergreen/trivia_quiz.py @@ -8,6 +8,7 @@ import discord  from discord.ext import commands  from fuzzywuzzy import fuzz +from bot.bot import Bot  from bot.constants import Roles @@ -23,7 +24,7 @@ WRONG_ANS_RESPONSE = [  class TriviaQuiz(commands.Cog):      """A cog for all quiz commands.""" -    def __init__(self, bot: commands.Bot) -> None: +    def __init__(self, bot: Bot) -> None:          self.bot = bot          self.questions = self.load_questions()          self.game_status = {}  # A variable to store the game status: either running or not running. @@ -40,11 +41,9 @@ class TriviaQuiz(commands.Cog):      def load_questions() -> dict:          """Load the questions from the JSON file."""          p = Path("bot", "resources", "evergreen", "trivia_quiz.json") -        with p.open(encoding="utf8") as json_data: -            questions = json.load(json_data) -            return questions +        return json.loads(p.read_text("utf8")) -    @commands.group(name="quiz", aliases=["trivia"], invoke_without_command=True) +    @commands.group(name="quiz", aliases=("trivia",), invoke_without_command=True)      async def quiz_game(self, ctx: commands.Context, category: str = None) -> None:          """          Start a quiz! @@ -61,10 +60,11 @@ class TriviaQuiz(commands.Cog):          # Stop game if running.          if self.game_status[ctx.channel.id] is True: -            return await ctx.send( +            await ctx.send(                  f"Game is already running..."                  f"do `{self.bot.command_prefix}quiz stop`"              ) +            return          # Send embed showing available categories if inputted category is invalid.          if category is None: @@ -127,7 +127,7 @@ class TriviaQuiz(commands.Cog):                  )              try: -                msg = await self.bot.wait_for('message', check=check, timeout=10) +                msg = await self.bot.wait_for("message", check=check, timeout=10)              except asyncio.TimeoutError:                  # In case of TimeoutError and the game has been stopped, then do nothing.                  if self.game_status[ctx.channel.id] is False: @@ -299,6 +299,6 @@ class TriviaQuiz(commands.Cog):          await channel.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Load the cog.""" +def setup(bot: Bot) -> None: +    """Load the TriviaQuiz cog."""      bot.add_cog(TriviaQuiz(bot)) diff --git a/bot/exts/evergreen/uptime.py b/bot/exts/evergreen/uptime.py deleted file mode 100644 index a9ad9dfb..00000000 --- a/bot/exts/evergreen/uptime.py +++ /dev/null @@ -1,33 +0,0 @@ -import logging - -import arrow -from dateutil.relativedelta import relativedelta -from discord.ext import commands - -from bot import start_time - -log = logging.getLogger(__name__) - - -class Uptime(commands.Cog): -    """A cog for posting the bot's uptime.""" - -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    @commands.command(name="uptime") -    async def uptime(self, ctx: commands.Context) -> None: -        """Responds with the uptime of the bot.""" -        difference = relativedelta(start_time - arrow.utcnow()) -        uptime_string = start_time.shift( -            seconds=-difference.seconds, -            minutes=-difference.minutes, -            hours=-difference.hours, -            days=-difference.days -        ).humanize() -        await ctx.send(f"I started up {uptime_string}.") - - -def setup(bot: commands.Bot) -> None: -    """Uptime Cog load.""" -    bot.add_cog(Uptime(bot)) diff --git a/bot/exts/evergreen/wikipedia.py b/bot/exts/evergreen/wikipedia.py index 068c4f43..83937438 100644 --- a/bot/exts/evergreen/wikipedia.py +++ b/bot/exts/evergreen/wikipedia.py @@ -20,7 +20,7 @@ WIKI_THUMBNAIL = (      "https://upload.wikimedia.org/wikipedia/en/thumb/8/80/Wikipedia-logo-v2.svg"      "/330px-Wikipedia-logo-v2.svg.png"  ) -WIKI_SNIPPET_REGEX = r'(<!--.*?-->|<[^>]*>)' +WIKI_SNIPPET_REGEX = r"(<!--.*?-->|<[^>]*>)"  WIKI_SEARCH_RESULT = (      "**[{name}]({url})**\n"      "{description}\n" @@ -39,18 +39,18 @@ class WikipediaSearch(commands.Cog):          async with self.bot.http_session.get(url=url) as resp:              if resp.status == 200:                  raw_data = await resp.json() -                number_of_results = raw_data['query']['searchinfo']['totalhits'] +                number_of_results = raw_data["query"]["searchinfo"]["totalhits"]                  if number_of_results: -                    results = raw_data['query']['search'] +                    results = raw_data["query"]["search"]                      lines = []                      for article in results:                          line = WIKI_SEARCH_RESULT.format( -                            name=article['title'], +                            name=article["title"],                              description=unescape(                                  re.sub( -                                    WIKI_SNIPPET_REGEX, '', article['snippet'] +                                    WIKI_SNIPPET_REGEX, "", article["snippet"]                                  )                              ),                              url=f"https://en.wikipedia.org/?curid={article['pageid']}" @@ -72,7 +72,7 @@ class WikipediaSearch(commands.Cog):                  return      @commands.cooldown(1, 10, commands.BucketType.user) -    @commands.command(name="wikipedia", aliases=["wiki"]) +    @commands.command(name="wikipedia", aliases=("wiki",))      async def wikipedia_search_command(self, ctx: commands.Context, *, search: str) -> None:          """Sends paginated top 10 results of Wikipedia search.."""          contents = await self.wiki_request(ctx.channel, search) @@ -90,5 +90,5 @@ class WikipediaSearch(commands.Cog):  def setup(bot: Bot) -> None: -    """Wikipedia Cog load.""" +    """Load the WikipediaSearch cog."""      bot.add_cog(WikipediaSearch(bot)) diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py index 14ec1041..d23afd6f 100644 --- a/bot/exts/evergreen/wolfram.py +++ b/bot/exts/evergreen/wolfram.py @@ -9,6 +9,7 @@ from discord import Embed  from discord.ext import commands  from discord.ext.commands import BucketType, Cog, Context, check, group +from bot.bot import Bot  from bot.constants import Colours, STAFF_ROLES, Wolfram  from bot.utils.pagination import ImagePaginator @@ -39,9 +40,11 @@ async def send_embed(      """Generate & send a response embed with Wolfram as the author."""      embed = Embed(colour=colour)      embed.description = message_txt -    embed.set_author(name="Wolfram Alpha", -                     icon_url=WOLF_IMAGE, -                     url="https://www.wolframalpha.com/") +    embed.set_author( +        name="Wolfram Alpha", +        icon_url=WOLF_IMAGE, +        url="https://www.wolframalpha.com/" +    )      if footer:          embed.set_footer(text=footer) @@ -55,10 +58,10 @@ def custom_cooldown(*ignore: List[int]) -> Callable:      """      Implement per-user and per-guild cooldowns for requests to the Wolfram API. -    A list of roles may be provided to ignore the per-user cooldown +    A list of roles may be provided to ignore the per-user cooldown.      """      async def predicate(ctx: Context) -> bool: -        if ctx.invoked_with == 'help': +        if ctx.invoked_with == "help":              # if the invoked command is help we don't want to increase the ratelimits since it's not actually              # invoking the command/making a request, so instead just check if the user/guild are on cooldown.              guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0  # if guild is on cooldown @@ -102,9 +105,9 @@ def custom_cooldown(*ignore: List[int]) -> Callable:      return check(predicate) -async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional[List[Tuple]]: +async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]:      """Get the Wolfram API pod pages for the provided query.""" -    async with ctx.channel.typing(): +    async with ctx.typing():          url_str = parse.urlencode({              "input": query,              "appid": APPID, @@ -117,7 +120,7 @@ async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional          request_url = QUERY.format(request="query", data=url_str)          async with bot.http_session.get(request_url) as response: -            json = await response.json(content_type='text/plain') +            json = await response.json(content_type="text/plain")          result = json["queryresult"] @@ -162,7 +165,7 @@ async def get_pod_pages(ctx: Context, bot: commands.Bot, query: str) -> Optional  class Wolfram(Cog):      """Commands for interacting with the Wolfram|Alpha API.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) @@ -179,7 +182,7 @@ class Wolfram(Cog):          query = QUERY.format(request="simple", data=url_str)          # Give feedback that the bot is working. -        async with ctx.channel.typing(): +        async with ctx.typing():              async with self.bot.http_session.get(query) as response:                  status = response.status                  image_bytes = await response.read() @@ -188,11 +191,11 @@ class Wolfram(Cog):              image_url = "attachment://image.png"              if status == 501: -                message = "Failed to get response" +                message = "Failed to get response."                  footer = ""                  color = Colours.soft_red              elif status == 400: -                message = "No input found" +                message = "No input found."                  footer = ""                  color = Colours.soft_red              elif status == 403: @@ -221,9 +224,11 @@ class Wolfram(Cog):              return          embed = Embed() -        embed.set_author(name="Wolfram Alpha", -                         icon_url=WOLF_IMAGE, -                         url="https://www.wolframalpha.com/") +        embed.set_author( +            name="Wolfram Alpha", +            icon_url=WOLF_IMAGE, +            url="https://www.wolframalpha.com/" +        )          embed.colour = Colours.soft_orange          await ImagePaginator.paginate(pages, ctx, embed) @@ -262,18 +267,18 @@ class Wolfram(Cog):          query = QUERY.format(request="result", data=url_str)          # Give feedback that the bot is working. -        async with ctx.channel.typing(): +        async with ctx.typing():              async with self.bot.http_session.get(query) as response:                  status = response.status                  response_text = await response.text()              if status == 501: -                message = "Failed to get response" +                message = "Failed to get response."                  color = Colours.soft_red              elif status == 400: -                message = "No input found" +                message = "No input found."                  color = Colours.soft_red -            elif response_text == "Error 1: Invalid appid": +            elif response_text == "Error 1: Invalid appid.":                  message = "Wolfram API key is invalid or missing."                  color = Colours.soft_red              else: @@ -283,6 +288,6 @@ class Wolfram(Cog):              await send_embed(ctx, message, color) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Load the Wolfram cog."""      bot.add_cog(Wolfram(bot)) diff --git a/bot/exts/evergreen/wonder_twins.py b/bot/exts/evergreen/wonder_twins.py index afc5346e..40edf785 100644 --- a/bot/exts/evergreen/wonder_twins.py +++ b/bot/exts/evergreen/wonder_twins.py @@ -2,15 +2,15 @@ import random  from pathlib import Path  import yaml -from discord.ext.commands import Bot, Cog, Context, command +from discord.ext.commands import Cog, Context, command + +from bot.bot import Bot  class WonderTwins(Cog):      """Cog for a Wonder Twins inspired command.""" -    def __init__(self, bot: Bot): -        self.bot = bot - +    def __init__(self):          with open(Path.cwd() / "bot" / "resources" / "evergreen" / "wonder_twins.yaml", "r", encoding="utf-8") as f:              info = yaml.load(f, Loader=yaml.FullLoader)              self.water_types = info["water_types"] @@ -38,7 +38,7 @@ class WonderTwins(Cog):              object_name = self.append_onto(adjective, object_name)          return f"{object_name} of {water_type}" -    @command(name="formof", aliases=["wondertwins", "wondertwin", "fo"]) +    @command(name="formof", aliases=("wondertwins", "wondertwin", "fo"))      async def form_of(self, ctx: Context) -> None:          """Command to send a Wonder Twins inspired phrase to the user invoking the command."""          await ctx.send(f"Form of {self.format_phrase()}!") @@ -46,4 +46,4 @@ class WonderTwins(Cog):  def setup(bot: Bot) -> None:      """Load the WonderTwins cog.""" -    bot.add_cog(WonderTwins(bot)) +    bot.add_cog(WonderTwins()) diff --git a/bot/exts/evergreen/xkcd.py b/bot/exts/evergreen/xkcd.py index 1ff98ca2..c98830bc 100644 --- a/bot/exts/evergreen/xkcd.py +++ b/bot/exts/evergreen/xkcd.py @@ -53,7 +53,7 @@ class XKCD(Cog):              await ctx.send(embed=embed)              return -        comic = randint(1, self.latest_comic_info['num']) if comic is None else comic.group(0) +        comic = randint(1, self.latest_comic_info["num"]) if comic is None else comic.group(0)          if comic == "latest":              info = self.latest_comic_info @@ -69,7 +69,7 @@ class XKCD(Cog):                      return          embed.title = f"XKCD comic #{info['num']}" -        embed.description = info['alt'] +        embed.description = info["alt"]          embed.url = f"{BASE_URL}/{info['num']}"          if info["img"][-3:] in ("jpg", "png", "gif"): @@ -87,5 +87,5 @@ class XKCD(Cog):  def setup(bot: Bot) -> None: -    """Loading the XKCD cog.""" +    """Load the XKCD cog."""      bot.add_cog(XKCD(bot)) diff --git a/bot/exts/halloween/8ball.py b/bot/exts/halloween/8ball.py index 1df48fbf..a2431190 100644 --- a/bot/exts/halloween/8ball.py +++ b/bot/exts/halloween/8ball.py @@ -6,28 +6,26 @@ from pathlib import Path  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) -with open(Path("bot/resources/halloween/responses.json"), "r", encoding="utf8") as f: -    responses = json.load(f) +RESPONSES = json.loads(Path("bot/resources/halloween/responses.json").read_text("utf8"))  class SpookyEightBall(commands.Cog):      """Spooky Eightball answers.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    @commands.command(aliases=('spooky8ball',)) +    @commands.command(aliases=("spooky8ball",))      async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None:          """Responds with a random response to a question.""" -        choice = random.choice(responses['responses']) +        choice = random.choice(RESPONSES["responses"])          msg = await ctx.send(choice[0])          if len(choice) > 1:              await asyncio.sleep(random.randint(2, 5))              await msg.edit(content=f"{choice[0]} \n{choice[1]}") -def setup(bot: commands.Bot) -> None: -    """Spooky Eight Ball Cog Load.""" -    bot.add_cog(SpookyEightBall(bot)) +def setup(bot: Bot) -> None: +    """Load the Spooky Eight Ball Cog.""" +    bot.add_cog(SpookyEightBall()) diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py index 40e21f40..4afd5913 100644 --- a/bot/exts/halloween/candy_collection.py +++ b/bot/exts/halloween/candy_collection.py @@ -6,6 +6,7 @@ import discord  from async_rediscache import RedisCache  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Channels, Month  from bot.utils.decorators import in_month @@ -21,11 +22,11 @@ EMOJIS = dict(      CANDY="\N{CANDY}",      SKULL="\N{SKULL}",      MEDALS=( -        '\N{FIRST PLACE MEDAL}', -        '\N{SECOND PLACE MEDAL}', -        '\N{THIRD PLACE MEDAL}', -        '\N{SPORTS MEDAL}', -        '\N{SPORTS MEDAL}', +        "\N{FIRST PLACE MEDAL}", +        "\N{SECOND PLACE MEDAL}", +        "\N{THIRD PLACE MEDAL}", +        "\N{SPORTS MEDAL}", +        "\N{SPORTS MEDAL}",      ),  ) @@ -40,7 +41,7 @@ class CandyCollection(commands.Cog):      candy_messages = RedisCache()      skull_messages = RedisCache() -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @in_month(Month.OCTOBER) @@ -60,15 +61,15 @@ class CandyCollection(commands.Cog):          # do random check for skull first as it has the lower chance          if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1:              await self.skull_messages.set(message.id, "skull") -            return await message.add_reaction(EMOJIS['SKULL']) +            await message.add_reaction(EMOJIS["SKULL"])          # check for the candy chance next -        if random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1: +        elif random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1:              await self.candy_messages.set(message.id, "candy") -            return await message.add_reaction(EMOJIS['CANDY']) +            await message.add_reaction(EMOJIS["CANDY"])      @in_month(Month.OCTOBER)      @commands.Cog.listener() -    async def on_reaction_add(self, reaction: discord.Reaction, user: discord.Member) -> None: +    async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.User, discord.Member]) -> None:          """Add/remove candies from a person if the reaction satisfies criteria."""          message = reaction.message          # check to ensure the reactor is human @@ -81,7 +82,7 @@ class CandyCollection(commands.Cog):          # if its not a candy or skull, and it is one of 10 most recent messages,          # proceed to add a skull/candy with higher chance -        if str(reaction.emoji) not in (EMOJIS['SKULL'], EMOJIS['CANDY']): +        if str(reaction.emoji) not in (EMOJIS["SKULL"], EMOJIS["CANDY"]):              recent_message_ids = map(                  lambda m: m.id,                  await self.hacktober_channel.history(limit=10).flatten() @@ -90,14 +91,14 @@ class CandyCollection(commands.Cog):                  await self.reacted_msg_chance(message)              return -        if await self.candy_messages.get(message.id) == "candy" and str(reaction.emoji) == EMOJIS['CANDY']: +        if await self.candy_messages.get(message.id) == "candy" and str(reaction.emoji) == EMOJIS["CANDY"]:              await self.candy_messages.delete(message.id)              if await self.candy_records.contains(user.id):                  await self.candy_records.increment(user.id)              else:                  await self.candy_records.set(user.id, 1) -        elif await self.skull_messages.get(message.id) == "skull" and str(reaction.emoji) == EMOJIS['SKULL']: +        elif await self.skull_messages.get(message.id) == "skull" and str(reaction.emoji) == EMOJIS["SKULL"]:              await self.skull_messages.delete(message.id)              if prev_record := await self.candy_records.get(user.id): @@ -105,7 +106,7 @@ class CandyCollection(commands.Cog):                  await self.candy_records.decrement(user.id, lost)                  if lost == prev_record: -                    await CandyCollection.send_spook_msg(user, message.channel, 'all of your') +                    await CandyCollection.send_spook_msg(user, message.channel, "all of your")                  else:                      await CandyCollection.send_spook_msg(user, message.channel, lost)              else: @@ -124,11 +125,11 @@ class CandyCollection(commands.Cog):          """          if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1:              await self.skull_messages.set(message.id, "skull") -            return await message.add_reaction(EMOJIS['SKULL']) +            await message.add_reaction(EMOJIS["SKULL"]) -        if random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1: +        elif random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1:              await self.candy_messages.set(message.id, "candy") -            return await message.add_reaction(EMOJIS['CANDY']) +            await message.add_reaction(EMOJIS["CANDY"])      @property      def hacktober_channel(self) -> discord.TextChannel: @@ -141,8 +142,10 @@ class CandyCollection(commands.Cog):      ) -> None:          """Send a spooky message."""          e = discord.Embed(colour=author.colour) -        e.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " -                          f"I took {candies} candies and quickly took flight.") +        e.set_author( +            name="Ghosts and Ghouls and Jack o' lanterns at night; " +            f"I took {candies} candies and quickly took flight." +        )          await channel.send(embed=e)      @staticmethod @@ -152,8 +155,12 @@ class CandyCollection(commands.Cog):      ) -> None:          """An alternative spooky message sent when user has no candies in the collection."""          embed = discord.Embed(color=author.color) -        embed.set_author(name="Ghosts and Ghouls and Jack o' lanterns at night; " -                              "I tried to take your candies but you had none to begin with!") +        embed.set_author( +            name=( +                "Ghosts and Ghouls and Jack o' lanterns at night; " +                "I tried to take your candies but you had none to begin with!" +            ) +        )          await channel.send(embed=embed)      @in_month(Month.OCTOBER) @@ -170,10 +177,10 @@ class CandyCollection(commands.Cog):              )              top_five = top_sorted[:5] -            return '\n'.join( +            return "\n".join(                  f"{EMOJIS['MEDALS'][index]} <@{record[0]}>: {record[1]}"                  for index, record in enumerate(top_five) -            ) if top_five else 'No Candies' +            ) if top_five else "No Candies"          e = discord.Embed(colour=discord.Colour.blurple())          e.add_field( @@ -182,7 +189,7 @@ class CandyCollection(commands.Cog):              inline=False          )          e.add_field( -            name='\u200b', +            name="\u200b",              value="Candies will randomly appear on messages sent. "                    "\nHit the candy when it appears as fast as possible to get the candy! "                    "\nBut beware the ghosts...", @@ -191,6 +198,6 @@ class CandyCollection(commands.Cog):          await ctx.send(embed=e) -def setup(bot: commands.Bot) -> None: -    """Candy Collection game Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Candy Collection Cog."""      bot.add_cog(CandyCollection(bot)) diff --git a/bot/exts/halloween/hacktober-issue-finder.py b/bot/exts/halloween/hacktober-issue-finder.py index 9deadde9..20a06770 100644 --- a/bot/exts/halloween/hacktober-issue-finder.py +++ b/bot/exts/halloween/hacktober-issue-finder.py @@ -3,10 +3,10 @@ import logging  import random  from typing import Dict, Optional -import aiohttp  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Month, Tokens  from bot.utils.decorators import in_month @@ -25,7 +25,7 @@ if GITHUB_TOKEN := Tokens.github:  class HacktoberIssues(commands.Cog):      """Find a random hacktober python issue on GitHub.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot          self.cache_normal = None          self.cache_timer_normal = datetime.datetime(1, 1, 1) @@ -41,7 +41,7 @@ class HacktoberIssues(commands.Cog):          If the command is run with beginner (`.hacktoberissues beginner`):          It will also narrow it down to the "first good issue" label.          """ -        with ctx.typing(): +        async with ctx.typing():              issues = await self.get_issues(ctx, option)              if issues is None:                  return @@ -59,40 +59,41 @@ class HacktoberIssues(commands.Cog):              log.debug("using cache")              return self.cache_normal -        async with aiohttp.ClientSession() as session: +        if option == "beginner": +            url = URL + '+label:"good first issue"' +            if self.cache_beginner is not None: +                page = random.randint(1, min(1000, self.cache_beginner["total_count"]) // 100) +                url += f"&page={page}" +        else: +            url = URL +            if self.cache_normal is not None: +                page = random.randint(1, min(1000, self.cache_normal["total_count"]) // 100) +                url += f"&page={page}" + +        log.debug(f"making api request to url: {url}") +        async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as response: +            if response.status != 200: +                log.error(f"expected 200 status (got {response.status}) by the GitHub api.") +                await ctx.send( +                    f"ERROR: expected 200 status (got {response.status}) by the GitHub api.\n" +                    f"{await response.text()}" +                ) +                return None +            data = await response.json() + +            if len(data["items"]) == 0: +                log.error(f"no issues returned by GitHub API, with url: {response.url}") +                await ctx.send(f"ERROR: no issues returned by GitHub API, with url: {response.url}") +                return None +              if option == "beginner": -                url = URL + '+label:"good first issue"' -                if self.cache_beginner is not None: -                    page = random.randint(1, min(1000, self.cache_beginner["total_count"]) // 100) -                    url += f"&page={page}" +                self.cache_beginner = data +                self.cache_timer_beginner = ctx.message.created_at              else: -                url = URL -                if self.cache_normal is not None: -                    page = random.randint(1, min(1000, self.cache_normal["total_count"]) // 100) -                    url += f"&page={page}" - -            log.debug(f"making api request to url: {url}") -            async with session.get(url, headers=REQUEST_HEADERS) as response: -                if response.status != 200: -                    log.error(f"expected 200 status (got {response.status}) from the GitHub api.") -                    await ctx.send(f"ERROR: expected 200 status (got {response.status}) from the GitHub api.") -                    await ctx.send(await response.text()) -                    return None -                data = await response.json() - -                if len(data["items"]) == 0: -                    log.error(f"no issues returned from GitHub api. with url: {response.url}") -                    await ctx.send(f"ERROR: no issues returned from GitHub api. with url: {response.url}") -                    return None - -                if option == "beginner": -                    self.cache_beginner = data -                    self.cache_timer_beginner = ctx.message.created_at -                else: -                    self.cache_normal = data -                    self.cache_timer_normal = ctx.message.created_at - -                return data +                self.cache_normal = data +                self.cache_timer_normal = ctx.message.created_at + +            return data      @staticmethod      def format_embed(issue: Dict) -> discord.Embed: @@ -103,7 +104,7 @@ class HacktoberIssues(commands.Cog):          labels = [label["name"] for label in issue["labels"]]          embed = discord.Embed(title=title) -        embed.description = body[:500] + '...' if len(body) > 500 else body +        embed.description = body[:500] + "..." if len(body) > 500 else body          embed.add_field(name="labels", value="\n".join(labels))          embed.url = issue_url          embed.set_footer(text=issue_url) @@ -111,6 +112,6 @@ class HacktoberIssues(commands.Cog):          return embed -def setup(bot: commands.Bot) -> None: -    """Hacktober issue finder Cog Load.""" +def setup(bot: Bot) -> None: +    """Load the HacktoberIssue finder."""      bot.add_cog(HacktoberIssues(bot)) diff --git a/bot/exts/halloween/hacktoberstats.py b/bot/exts/halloween/hacktoberstats.py index d9fc0e8a..b74e680b 100644 --- a/bot/exts/halloween/hacktoberstats.py +++ b/bot/exts/halloween/hacktoberstats.py @@ -5,12 +5,12 @@ from collections import Counter  from datetime import datetime, timedelta  from typing import List, Optional, Tuple, Union -import aiohttp  import discord  from async_rediscache import RedisCache  from discord.ext import commands -from bot.constants import Channels, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS +from bot.bot import Bot +from bot.constants import Channels, Colours, Month, NEGATIVE_REPLIES, Tokens, WHITELISTED_CHANNELS  from bot.utils.decorators import in_month, whitelist_override  log = logging.getLogger(__name__) @@ -39,7 +39,7 @@ class HacktoberStats(commands.Cog):      # Stores mapping of user IDs and GitHub usernames      linked_accounts = RedisCache() -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) @@ -83,15 +83,15 @@ class HacktoberStats(commands.Cog):          if github_username:              if await self.linked_accounts.contains(author_id):                  old_username = await self.linked_accounts.get(author_id) -                logging.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'") +                log.info(f"{author_id} has changed their github link from '{old_username}' to '{github_username}'")                  await ctx.send(f"{author_mention}, your GitHub username has been updated to: '{github_username}'")              else: -                logging.info(f"{author_id} has added a github link to '{github_username}'") +                log.info(f"{author_id} has added a github link to '{github_username}'")                  await ctx.send(f"{author_mention}, your GitHub username has been added")              await self.linked_accounts.set(author_id, github_username)          else: -            logging.info(f"{author_id} tried to link a GitHub account but didn't provide a username") +            log.info(f"{author_id} tried to link a GitHub account but didn't provide a username")              await ctx.send(f"{author_mention}, a GitHub username is required to link your account")      @in_month(Month.SEPTEMBER, Month.OCTOBER, Month.NOVEMBER) @@ -138,7 +138,7 @@ class HacktoberStats(commands.Cog):              if prs:                  stats_embed = await self.build_embed(github_username, prs) -                await ctx.send('Here are some stats!', embed=stats_embed) +                await ctx.send("Here are some stats!", embed=stats_embed)              else:                  await ctx.send(f"No valid Hacktoberfest PRs found for '{github_username}'") @@ -157,7 +157,7 @@ class HacktoberStats(commands.Cog):          stats_embed = discord.Embed(              title=f"{github_username}'s Hacktoberfest", -            color=discord.Color(0x9c4af7), +            color=Colours.purple,              description=(                  f"{github_username} has made {n} valid "                  f"{self._contributionator(n)} in " @@ -188,8 +188,7 @@ class HacktoberStats(commands.Cog):          logging.info(f"Hacktoberfest PR built for GitHub user '{github_username}'")          return stats_embed -    @staticmethod -    async def get_october_prs(github_username: str) -> Optional[List[dict]]: +    async def get_october_prs(self, github_username: str) -> Optional[List[dict]]:          """          Query GitHub's API for PRs created during the month of October by github_username. @@ -212,7 +211,7 @@ class HacktoberStats(commands.Cog):          Otherwise, return empty list.          None will be returned when the GitHub user was not found.          """ -        logging.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'") +        log.info(f"Fetching Hacktoberfest Stats for GitHub user: '{github_username}'")          base_url = "https://api.github.com/search/issues?q="          action_type = "pr"          is_query = "public" @@ -228,24 +227,24 @@ class HacktoberStats(commands.Cog):              f"+created:{date_range}"              f"&per_page={per_page}"          ) -        logging.debug(f"GitHub query URL generated: {query_url}") +        log.debug(f"GitHub query URL generated: {query_url}") -        jsonresp = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS) -        if "message" in jsonresp.keys(): +        jsonresp = await self._fetch_url(query_url, REQUEST_HEADERS) +        if "message" in jsonresp:              # One of the parameters is invalid, short circuit for now              api_message = jsonresp["errors"][0]["message"]              # Ignore logging non-existent users or users we do not have permission to see              if api_message == GITHUB_NONEXISTENT_USER_MESSAGE: -                logging.debug(f"No GitHub user found named '{github_username}'") +                log.debug(f"No GitHub user found named '{github_username}'")                  return              else: -                logging.error(f"GitHub API request for '{github_username}' failed with message: {api_message}") +                log.error(f"GitHub API request for '{github_username}' failed with message: {api_message}")              return []  # No October PRs were found due to error          if jsonresp["total_count"] == 0:              # Short circuit if there aren't any PRs -            logging.info(f"No October PRs found for GitHub user: '{github_username}'") +            log.info(f"No October PRs found for GitHub user: '{github_username}'")              return []          logging.info(f"Found {len(jsonresp['items'])} Hacktoberfest PRs for GitHub user: '{github_username}'") @@ -253,20 +252,20 @@ class HacktoberStats(commands.Cog):          oct3 = datetime(int(CURRENT_YEAR), 10, 3, 23, 59, 59, tzinfo=None)          hackto_topics = {}  # cache whether each repo has the appropriate topic (bool values)          for item in jsonresp["items"]: -            shortname = HacktoberStats._get_shortname(item["repository_url"]) +            shortname = self._get_shortname(item["repository_url"])              itemdict = {                  "repo_url": f"https://www.github.com/{shortname}",                  "repo_shortname": shortname,                  "created_at": datetime.strptime( -                    item["created_at"], r"%Y-%m-%dT%H:%M:%SZ" +                    item["created_at"], "%Y-%m-%dT%H:%M:%SZ"                  ),                  "number": item["number"]              }              # If the PR has 'invalid' or 'spam' labels, the PR must be              # either merged or approved for it to be included -            if HacktoberStats._has_label(item, ["invalid", "spam"]): -                if not await HacktoberStats._is_accepted(itemdict): +            if self._has_label(item, ["invalid", "spam"]): +                if not await self._is_accepted(itemdict):                      continue              # PRs before oct 3 no need to check for topics @@ -277,21 +276,20 @@ class HacktoberStats(commands.Cog):                  continue              # Checking PR's labels for "hacktoberfest-accepted" -            if HacktoberStats._has_label(item, "hacktoberfest-accepted"): +            if self._has_label(item, "hacktoberfest-accepted"):                  outlist.append(itemdict)                  continue              # No need to query GitHub if repo topics are fetched before already -            if shortname in hackto_topics.keys(): -                if hackto_topics[shortname]: -                    outlist.append(itemdict) -                    continue +            if hackto_topics.get(shortname): +                outlist.append(itemdict) +                continue              # Fetch topics for the PR's repo              topics_query_url = f"https://api.github.com/repos/{shortname}/topics" -            logging.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") -            jsonresp2 = await HacktoberStats._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER) +            log.debug(f"Fetching repo topics for {shortname} with url: {topics_query_url}") +            jsonresp2 = await self._fetch_url(topics_query_url, GITHUB_TOPICS_ACCEPT_HEADER)              if jsonresp2.get("names") is None: -                logging.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}") +                log.error(f"Error fetching topics for {shortname}: {jsonresp2['message']}")                  continue  # Assume the repo doesn't have the `hacktoberfest` topic if API  request errored              # PRs after oct 3 that doesn't have 'hacktoberfest-accepted' label @@ -301,13 +299,10 @@ class HacktoberStats(commands.Cog):                  outlist.append(itemdict)          return outlist -    @staticmethod -    async def _fetch_url(url: str, headers: dict) -> dict: +    async def _fetch_url(self, url: str, headers: dict) -> dict:          """Retrieve API response from URL.""" -        async with aiohttp.ClientSession() as session: -            async with session.get(url, headers=headers) as resp: -                jsonresp = await resp.json() -        return jsonresp +        async with self.bot.http_session.get(url, headers=headers) as resp: +            return await resp.json()      @staticmethod      def _has_label(pr: dict, labels: Union[List[str], str]) -> bool: @@ -319,40 +314,36 @@ class HacktoberStats(commands.Cog):          """          if not pr.get("labels"):  # if PR has no labels              return False -        if (isinstance(labels, str)) and (any(label["name"].casefold() == labels for label in pr["labels"])): +        if isinstance(labels, str) and any(label["name"].casefold() == labels for label in pr["labels"]):              return True          for item in labels:              if any(label["name"].casefold() == item for label in pr["labels"]):                  return True          return False -    @staticmethod -    async def _is_accepted(pr: dict) -> bool: +    async def _is_accepted(self, pr: dict) -> bool:          """Check if a PR is merged, approved, or labelled hacktoberfest-accepted."""          # checking for merge status -        query_url = f"https://api.github.com/repos/{pr['repo_shortname']}/pulls/" -        query_url += str(pr["number"]) -        jsonresp = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS) - -        if "message" in jsonresp.keys(): -            logging.error( -                f"Error fetching PR stats for #{pr['number']} in repo {pr['repo_shortname']}:\n" -                f"{jsonresp['message']}" -            ) +        query_url = f"https://api.github.com/repos/{pr['repo_shortname']}/pulls/{pr['number']}" +        jsonresp = await self._fetch_url(query_url, REQUEST_HEADERS) + +        if message := jsonresp.get("message"): +            log.error(f"Error fetching PR stats for #{pr['number']} in repo {pr['repo_shortname']}:\n{message}")              return False -        if ("merged" in jsonresp.keys()) and jsonresp["merged"]: + +        if jsonresp.get("merged"):              return True          # checking for the label, using `jsonresp` which has the label information -        if HacktoberStats._has_label(jsonresp, "hacktoberfest-accepted"): +        if self._has_label(jsonresp, "hacktoberfest-accepted"):              return True          # checking approval          query_url += "/reviews" -        jsonresp2 = await HacktoberStats._fetch_url(query_url, REQUEST_HEADERS) +        jsonresp2 = await self._fetch_url(query_url, REQUEST_HEADERS)          if isinstance(jsonresp2, dict):              # if API request is unsuccessful it will be a dict with the error in 'message' -            logging.error( +            log.error(                  f"Error fetching PR reviews for #{pr['number']} in repo {pr['repo_shortname']}:\n"                  f"{jsonresp2['message']}"              ) @@ -363,9 +354,8 @@ class HacktoberStats(commands.Cog):          # loop through reviews and check for approval          for item in jsonresp2: -            if "status" in item.keys(): -                if item['status'] == "APPROVED": -                    return True +            if item.get("status") == "APPROVED": +                return True          return False      @staticmethod @@ -381,8 +371,7 @@ class HacktoberStats(commands.Cog):          exp = r"https?:\/\/api.github.com\/repos\/([/\-\_\.\w]+)"          return re.findall(exp, in_url)[0] -    @staticmethod -    async def _categorize_prs(prs: List[dict]) -> tuple: +    async def _categorize_prs(self, prs: List[dict]) -> tuple:          """          Categorize PRs into 'in_review' and 'accepted' and returns as a tuple. @@ -397,9 +386,9 @@ class HacktoberStats(commands.Cog):          in_review = []          accepted = []          for pr in prs: -            if (pr['created_at'] + timedelta(REVIEW_DAYS)) > now: +            if (pr["created_at"] + timedelta(REVIEW_DAYS)) > now:                  in_review.append(pr) -            elif (pr['created_at'] <= oct3) or await HacktoberStats._is_accepted(pr): +            elif (pr["created_at"] <= oct3) or await self._is_accepted(pr):                  accepted.append(pr)          return in_review, accepted @@ -438,14 +427,14 @@ class HacktoberStats(commands.Cog):              return "contributions"      @staticmethod -    def _author_mention_from_context(ctx: commands.Context) -> Tuple: +    def _author_mention_from_context(ctx: commands.Context) -> Tuple[str, str]:          """Return stringified Message author ID and mentionable string from commands.Context.""" -        author_id = str(ctx.message.author.id) -        author_mention = ctx.message.author.mention +        author_id = str(ctx.author.id) +        author_mention = ctx.author.mention          return author_id, author_mention -def setup(bot: commands.Bot) -> None: -    """Hacktoberstats Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Hacktober Stats Cog."""      bot.add_cog(HacktoberStats(bot)) diff --git a/bot/exts/halloween/halloween_facts.py b/bot/exts/halloween/halloween_facts.py index 7eb6d56f..5ad8cc57 100644 --- a/bot/exts/halloween/halloween_facts.py +++ b/bot/exts/halloween/halloween_facts.py @@ -8,6 +8,8 @@ from typing import Tuple  import discord  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__)  SPOOKY_EMOJIS = [ @@ -20,23 +22,19 @@ SPOOKY_EMOJIS = [      "\N{SKULL AND CROSSBONES}",      "\N{SPIDER WEB}",  ] -PUMPKIN_ORANGE = discord.Color(0xFF7518) +PUMPKIN_ORANGE = 0xFF7518  INTERVAL = timedelta(hours=6).total_seconds() +FACTS = json.loads(Path("bot/resources/halloween/halloween_facts.json").read_text("utf8")) +FACTS = list(enumerate(FACTS)) +  class HalloweenFacts(commands.Cog):      """A Cog for displaying interesting facts about Halloween.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -        with open(Path("bot/resources/halloween/halloween_facts.json"), "r", encoding="utf8") as file: -            self.halloween_facts = json.load(file) -        self.facts = list(enumerate(self.halloween_facts)) -        random.shuffle(self.facts) -      def random_fact(self) -> Tuple[int, str]:          """Return a random fact from the loaded facts.""" -        return random.choice(self.facts) +        return random.choice(FACTS)      @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact")      async def get_random_fact(self, ctx: commands.Context) -> None: @@ -53,6 +51,6 @@ class HalloweenFacts(commands.Cog):          return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE) -def setup(bot: commands.Bot) -> None: -    """Halloween facts Cog load.""" -    bot.add_cog(HalloweenFacts(bot)) +def setup(bot: Bot) -> None: +    """Load the Halloween Facts Cog.""" +    bot.add_cog(HalloweenFacts()) diff --git a/bot/exts/halloween/halloweenify.py b/bot/exts/halloween/halloweenify.py index 596c6682..83cfbaa7 100644 --- a/bot/exts/halloween/halloweenify.py +++ b/bot/exts/halloween/halloweenify.py @@ -1,42 +1,40 @@  import logging -from json import load +from json import loads  from pathlib import Path  from random import choice  import discord  from discord.errors import Forbidden  from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType +from discord.ext.commands import BucketType + +from bot.bot import Bot  log = logging.getLogger(__name__) +HALLOWEENIFY_DATA = loads(Path("bot/resources/halloween/halloweenify.json").read_text("utf8")) +  class Halloweenify(commands.Cog):      """A cog to change a invokers nickname to a spooky one!""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @commands.cooldown(1, 300, BucketType.user)      @commands.command()      async def halloweenify(self, ctx: commands.Context) -> None:          """Change your nickname into a much spookier one!"""          async with ctx.typing(): -            with open(Path("bot/resources/halloween/halloweenify.json"), "r", encoding="utf8") as f: -                data = load(f) -              # Choose a random character from our list we loaded above and set apart the nickname and image url. -            character = choice(data["characters"]) -            nickname = ''.join([nickname for nickname in character]) -            image = ''.join([character[nickname] for nickname in character]) +            character = choice(HALLOWEENIFY_DATA["characters"]) +            nickname = "".join(nickname for nickname in character) +            image = "".join(character[nickname] for nickname in character)              # Build up a Embed              embed = discord.Embed()              embed.colour = discord.Colour.dark_orange()              embed.title = "Not spooky enough?"              embed.description = ( -                f"**{ctx.author.display_name}** wasn\'t spooky enough for you? That\'s understandable, " -                f"{ctx.author.display_name} isn\'t scary at all! " +                f"**{ctx.author.display_name}** wasn't spooky enough for you? That's understandable, " +                f"{ctx.author.display_name} isn't scary at all! "                  "Let me think of something better. Hmm... I got it!\n\n "              )              embed.set_image(url=image) @@ -61,6 +59,6 @@ class Halloweenify(commands.Cog):          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Halloweenify Cog load.""" -    bot.add_cog(Halloweenify(bot)) +def setup(bot: Bot) -> None: +    """Load the Halloweenify Cog.""" +    bot.add_cog(Halloweenify()) diff --git a/bot/exts/halloween/monsterbio.py b/bot/exts/halloween/monsterbio.py index 016a66d1..69e898cb 100644 --- a/bot/exts/halloween/monsterbio.py +++ b/bot/exts/halloween/monsterbio.py @@ -6,20 +6,19 @@ from pathlib import Path  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -with open(Path("bot/resources/halloween/monster.json"), "r", encoding="utf8") as f: -    TEXT_OPTIONS = json.load(f)  # Data for a mad-lib style generation of text +TEXT_OPTIONS = json.loads( +    Path("bot/resources/halloween/monster.json").read_text("utf8") +)  # Data for a mad-lib style generation of text  class MonsterBio(commands.Cog):      """A cog that generates a spooky monster biography.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      def generate_name(self, seeded_random: random.Random) -> str:          """Generates a name (for either monster species or monster name)."""          n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"])) @@ -28,7 +27,7 @@ class MonsterBio(commands.Cog):      @commands.command(brief="Sends your monster bio!")      async def monsterbio(self, ctx: commands.Context) -> None:          """Sends a description of a monster.""" -        seeded_random = random.Random(ctx.message.author.id)  # Seed a local Random instance rather than the system one +        seeded_random = random.Random(ctx.author.id)  # Seed a local Random instance rather than the system one          name = self.generate_name(seeded_random)          species = self.generate_name(seeded_random) @@ -39,7 +38,7 @@ class MonsterBio(commands.Cog):                  continue              options = seeded_random.sample(TEXT_OPTIONS[key], value) -            words[key] = ' '.join(options) +            words[key] = " ".join(options)          embed = discord.Embed(              title=f"{name}'s Biography", @@ -50,6 +49,6 @@ class MonsterBio(commands.Cog):          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Monster bio Cog load.""" -    bot.add_cog(MonsterBio(bot)) +def setup(bot: Bot) -> None: +    """Load the Monster Bio Cog.""" +    bot.add_cog(MonsterBio()) diff --git a/bot/exts/halloween/monstersurvey.py b/bot/exts/halloween/monstersurvey.py index 80196825..96cda11e 100644 --- a/bot/exts/halloween/monstersurvey.py +++ b/bot/exts/halloween/monstersurvey.py @@ -1,6 +1,6 @@  import json  import logging -import os +import pathlib  from discord import Embed  from discord.ext import commands @@ -9,8 +9,8 @@ from discord.ext.commands import Bot, Cog, Context  log = logging.getLogger(__name__)  EMOJIS = { -    'SUCCESS': u'\u2705', -    'ERROR': u'\u274C' +    "SUCCESS": u"\u2705", +    "ERROR": u"\u274C"  } @@ -23,18 +23,15 @@ class MonsterSurvey(Cog):      Users may change their vote, but only their current vote will be counted.      """ -    def __init__(self, bot: Bot): +    def __init__(self):          """Initializes values for the bot to use within the voting commands.""" -        self.bot = bot -        self.registry_location = os.path.join(os.getcwd(), 'bot', 'resources', 'halloween', 'monstersurvey.json') -        with open(self.registry_location, 'r', encoding="utf8") as jason: -            self.voter_registry = json.load(jason) +        self.registry_path = pathlib.Path("bot", "resources", "halloween", "monstersurvey.json") +        self.voter_registry = json.loads(self.registry_path.read_text("utf8"))      def json_write(self) -> None:          """Write voting results to a local JSON file."""          log.info("Saved Monster Survey Results") -        with open(self.registry_location, 'w', encoding="utf8") as jason: -            json.dump(self.voter_registry, jason, indent=2) +        self.registry_path.write_text(json.dumps(self.voter_registry, indent=2))      def cast_vote(self, id: int, monster: str) -> None:          """ @@ -43,54 +40,55 @@ class MonsterSurvey(Cog):          If the user has already voted, their existing vote is removed.          """          vr = self.voter_registry -        for m in vr.keys(): -            if id not in vr[m]['votes'] and m == monster: -                vr[m]['votes'].append(id) +        for m in vr: +            if id not in vr[m]["votes"] and m == monster: +                vr[m]["votes"].append(id)              else: -                if id in vr[m]['votes'] and m != monster: -                    vr[m]['votes'].remove(id) +                if id in vr[m]["votes"] and m != monster: +                    vr[m]["votes"].remove(id)      def get_name_by_leaderboard_index(self, n: int) -> str:          """Return the monster at the specified leaderboard index."""          n = n - 1          vr = self.voter_registry -        top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) +        top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True)          name = top[n] if n >= 0 else None          return name      @commands.group( -        name='monster', -        aliases=('mon',) +        name="monster", +        aliases=("mon",)      )      async def monster_group(self, ctx: Context) -> None:          """The base voting command. If nothing is called, then it will return an embed."""          if ctx.invoked_subcommand is None:              async with ctx.typing():                  default_embed = Embed( -                    title='Monster Voting', +                    title="Monster Voting",                      color=0xFF6800, -                    description='Vote for your favorite monster!' +                    description="Vote for your favorite monster!"                  )                  default_embed.add_field( -                    name='.monster show monster_name(optional)', -                    value='Show a specific monster. If none is listed, it will give you an error with valid choices.', -                    inline=False) +                    name=".monster show monster_name(optional)", +                    value="Show a specific monster. If none is listed, it will give you an error with valid choices.", +                    inline=False +                )                  default_embed.add_field( -                    name='.monster vote monster_name', -                    value='Vote for a specific monster. You get one vote, but can change it at any time.', +                    name=".monster vote monster_name", +                    value="Vote for a specific monster. You get one vote, but can change it at any time.",                      inline=False                  )                  default_embed.add_field( -                    name='.monster leaderboard', -                    value='Which monster has the most votes? This command will tell you.', +                    name=".monster leaderboard", +                    value="Which monster has the most votes? This command will tell you.",                      inline=False                  ) -                default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry.keys())}") +                default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry)}")              await ctx.send(embed=default_embed)      @monster_group.command( -        name='vote' +        name="vote"      )      async def monster_vote(self, ctx: Context, name: str = None) -> None:          """ @@ -111,37 +109,37 @@ class MonsterSurvey(Cog):                  name = name.lower()              vote_embed = Embed( -                name='Monster Voting', +                name="Monster Voting",                  color=0xFF6800              )              m = self.voter_registry.get(name)              if m is None: -                vote_embed.description = f'You cannot vote for {name} because it\'s not in the running.' +                vote_embed.description = f"You cannot vote for {name} because it's not in the running."                  vote_embed.add_field( -                    name='Use `.monster show {monster_name}` for more information on a specific monster', -                    value='or use `.monster vote {monster}` to cast your vote for said monster.', +                    name="Use `.monster show {monster_name}` for more information on a specific monster", +                    value="or use `.monster vote {monster}` to cast your vote for said monster.",                      inline=False                  )                  vote_embed.add_field( -                    name='You may vote for or show the following monsters:', -                    value=f"{', '.join(self.voter_registry.keys())}" +                    name="You may vote for or show the following monsters:", +                    value=", ".join(self.voter_registry.keys())                  )              else:                  self.cast_vote(ctx.author.id, name)                  vote_embed.add_field( -                    name='Vote successful!', -                    value=f'You have successfully voted for {m["full_name"]}!', +                    name="Vote successful!", +                    value=f"You have successfully voted for {m['full_name']}!",                      inline=False                  ) -                vote_embed.set_thumbnail(url=m['image']) +                vote_embed.set_thumbnail(url=m["image"])                  vote_embed.set_footer(text="Please note that any previous votes have been removed.")                  self.json_write()          await ctx.send(embed=vote_embed)      @monster_group.command( -        name='show' +        name="show"      )      async def monster_show(self, ctx: Context, name: str = None) -> None:          """Shows the named monster. If one is not named, it sends the default voting embed instead.""" @@ -159,41 +157,43 @@ class MonsterSurvey(Cog):              m = self.voter_registry.get(name)              if not m: -                await ctx.send('That monster does not exist.') +                await ctx.send("That monster does not exist.")                  await ctx.invoke(self.monster_vote)                  return -            embed = Embed(title=m['full_name'], color=0xFF6800) -            embed.add_field(name='Summary', value=m['summary']) -            embed.set_image(url=m['image']) -            embed.set_footer(text=f'To vote for this monster, type .monster vote {name}') +            embed = Embed(title=m["full_name"], color=0xFF6800) +            embed.add_field(name="Summary", value=m["summary"]) +            embed.set_image(url=m["image"]) +            embed.set_footer(text=f"To vote for this monster, type .monster vote {name}")          await ctx.send(embed=embed)      @monster_group.command( -        name='leaderboard', -        aliases=('lb',) +        name="leaderboard", +        aliases=("lb",)      )      async def monster_leaderboard(self, ctx: Context) -> None:          """Shows the current standings."""          async with ctx.typing():              vr = self.voter_registry -            top = sorted(vr, key=lambda k: len(vr[k]['votes']), reverse=True) -            total_votes = sum(len(m['votes']) for m in self.voter_registry.values()) +            top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True) +            total_votes = sum(len(m["votes"]) for m in self.voter_registry.values())              embed = Embed(title="Monster Survey Leader Board", color=0xFF6800)              for rank, m in enumerate(top): -                votes = len(vr[m]['votes']) +                votes = len(vr[m]["votes"])                  percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0 -                embed.add_field(name=f"{rank+1}. {vr[m]['full_name']}", -                                value=( -                                    f"{votes} votes. {percentage:.1f}% of total votes.\n" -                                    f"Vote for this monster by typing " -                                    f"'.monster vote {m}'\n" -                                    f"Get more information on this monster by typing " -                                    f"'.monster show {m}'" -                                ), -                                inline=False) +                embed.add_field( +                    name=f"{rank+1}. {vr[m]['full_name']}", +                    value=( +                        f"{votes} votes. {percentage:.1f}% of total votes.\n" +                        f"Vote for this monster by typing " +                        f"'.monster vote {m}'\n" +                        f"Get more information on this monster by typing " +                        f"'.monster show {m}'" +                    ), +                    inline=False +                )              embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ") @@ -201,4 +201,5 @@ class MonsterSurvey(Cog):  def setup(bot: Bot) -> None: -    """Monster survey Cog load.""" +    """Load the Monster Survey Cog.""" +    bot.add_cog(MonsterSurvey()) diff --git a/bot/exts/halloween/scarymovie.py b/bot/exts/halloween/scarymovie.py index 0807eca6..f4cf41db 100644 --- a/bot/exts/halloween/scarymovie.py +++ b/bot/exts/halloween/scarymovie.py @@ -2,24 +2,25 @@ import logging  import random  from os import environ -import aiohttp  from discord import Embed  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) -TMDB_API_KEY = environ.get('TMDB_API_KEY') -TMDB_TOKEN = environ.get('TMDB_TOKEN') +TMDB_API_KEY = environ.get("TMDB_API_KEY") +TMDB_TOKEN = environ.get("TMDB_TOKEN")  class ScaryMovie(commands.Cog):      """Selects a random scary movie and embeds info into Discord chat.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot -    @commands.command(name='scarymovie', alias=['smovie']) +    @commands.command(name="scarymovie", alias=["smovie"])      async def random_movie(self, ctx: commands.Context) -> None:          """Randomly select a scary movie and display information about it."""          async with ctx.typing(): @@ -28,36 +29,34 @@ class ScaryMovie(commands.Cog):          await ctx.send(embed=movie_details) -    @staticmethod -    async def select_movie() -> dict: +    async def select_movie(self) -> dict:          """Selects a random movie and returns a JSON of movie details from TMDb.""" -        url = 'https://api.themoviedb.org/4/discover/movie' +        url = "https://api.themoviedb.org/4/discover/movie"          params = { -            'with_genres': '27', -            'vote_count.gte': '5' +            "with_genres": "27", +            "vote_count.gte": "5"          }          headers = { -            'Authorization': 'Bearer ' + TMDB_TOKEN, -            'Content-Type': 'application/json;charset=utf-8' +            "Authorization": "Bearer " + TMDB_TOKEN, +            "Content-Type": "application/json;charset=utf-8"          }          # Get total page count of horror movies -        async with aiohttp.ClientSession() as session: -            response = await session.get(url=url, params=params, headers=headers) -            total_pages = await response.json() -            total_pages = total_pages.get('total_pages') - -            # Get movie details from one random result on a random page -            params['page'] = random.randint(1, total_pages) -            response = await session.get(url=url, params=params, headers=headers) -            response = await response.json() -            selection_id = random.choice(response.get('results')).get('id') - -            # Get full details and credits -            selection = await session.get( -                url='https://api.themoviedb.org/3/movie/' + str(selection_id), -                params={'api_key': TMDB_API_KEY, 'append_to_response': 'credits'} -            ) +        async with self.bot.http_session.get(url=url, params=params, headers=headers) as response: +            data = await response.json() +            total_pages = data.get("total_pages") + +        # Get movie details from one random result on a random page +        params["page"] = random.randint(1, total_pages) +        async with self.bot.http_session.get(url=url, params=params, headers=headers) as response: +            data = await response.json() +            selection_id = random.choice(data.get("results")).get("id") + +        # Get full details and credits +        async with self.bot.http_session.get( +            url=f"https://api.themoviedb.org/3/movie/{selection_id}", +            params={"api_key": TMDB_API_KEY, "append_to_response": "credits"} +        ) as selection:              return await selection.json() @@ -67,40 +66,37 @@ class ScaryMovie(commands.Cog):          # Build the relevant URLs.          movie_id = movie.get("id")          poster_path = movie.get("poster_path") -        tmdb_url = f'https://www.themoviedb.org/movie/{movie_id}' if movie_id else None -        poster = f'https://image.tmdb.org/t/p/original{poster_path}' if poster_path else None +        tmdb_url = f"https://www.themoviedb.org/movie/{movie_id}" if movie_id else None +        poster = f"https://image.tmdb.org/t/p/original{poster_path}" if poster_path else None          # Get cast names          cast = [] -        for actor in movie.get('credits', {}).get('cast', [])[:3]: -            cast.append(actor.get('name')) +        for actor in movie.get("credits", {}).get("cast", [])[:3]: +            cast.append(actor.get("name"))          # Get director name -        director = movie.get('credits', {}).get('crew', []) +        director = movie.get("credits", {}).get("crew", [])          if director: -            director = director[0].get('name') +            director = director[0].get("name")          # Determine the spookiness rating -        rating = '' -        rating_count = movie.get('vote_average', 0) - -        if rating_count: -            rating_count /= 2 +        rating = "" +        rating_count = movie.get("vote_average", 0) / 2          for _ in range(int(rating_count)): -            rating += ':skull:' +            rating += ":skull:"          if (rating_count % 1) >= .5: -            rating += ':bat:' +            rating += ":bat:"          # Try to get year of release and runtime -        year = movie.get('release_date', [])[:4] -        runtime = movie.get('runtime') +        year = movie.get("release_date", [])[:4] +        runtime = movie.get("runtime")          runtime = f"{runtime} minutes" if runtime else None          # Not all these attributes will always be present          movie_attributes = {              "Directed by": director, -            "Starring": ', '.join(cast), +            "Starring": ", ".join(cast),              "Running time": runtime,              "Release year": year,              "Spookiness rating": rating, @@ -108,9 +104,9 @@ class ScaryMovie(commands.Cog):          embed = Embed(              colour=0x01d277, -            title='**' + movie.get('title') + '**', +            title=f"**{movie.get('title')}**",              url=tmdb_url, -            description=movie.get('overview') +            description=movie.get("overview")          )          if poster: @@ -127,6 +123,6 @@ class ScaryMovie(commands.Cog):          return embed -def setup(bot: commands.Bot) -> None: -    """Scary movie Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Scary Movie Cog."""      bot.add_cog(ScaryMovie(bot)) diff --git a/bot/exts/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py deleted file mode 100644 index 2d7df678..00000000 --- a/bot/exts/halloween/spookyavatar.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -import os -from io import BytesIO - -import aiohttp -import discord -from PIL import Image -from discord.ext import commands - -from bot.utils.halloween import spookifications - -log = logging.getLogger(__name__) - - -class SpookyAvatar(commands.Cog): -    """A cog that spookifies an avatar.""" - -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    async def get(self, url: str) -> bytes: -        """Returns the contents of the supplied URL.""" -        async with aiohttp.ClientSession() as session: -            async with session.get(url) as resp: -                return await resp.read() - -    @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), -                      brief='Spookify an user\'s avatar.') -    async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: -        """A command to print the user's spookified avatar.""" -        if user is None: -            user = ctx.message.author - -        async with ctx.typing(): -            embed = discord.Embed(colour=0xFF0000) -            embed.title = "Is this you or am I just really paranoid?" -            embed.set_author(name=str(user.name), icon_url=user.avatar_url) - -            image_bytes = await ctx.author.avatar_url.read() -            im = Image.open(BytesIO(image_bytes)) -            modified_im = spookifications.get_random_effect(im) -            modified_im.save(str(ctx.message.id)+'.png') -            f = discord.File(str(ctx.message.id)+'.png') -            embed.set_image(url='attachment://'+str(ctx.message.id)+'.png') - -        await ctx.send(file=f, embed=embed) -        os.remove(str(ctx.message.id)+'.png') - - -def setup(bot: commands.Bot) -> None: -    """Spooky avatar Cog load.""" -    bot.add_cog(SpookyAvatar(bot)) diff --git a/bot/exts/halloween/spookygif.py b/bot/exts/halloween/spookygif.py index f402437f..9511d407 100644 --- a/bot/exts/halloween/spookygif.py +++ b/bot/exts/halloween/spookygif.py @@ -1,38 +1,38 @@  import logging -import aiohttp  import discord  from discord.ext import commands -from bot.constants import Tokens +from bot.bot import Bot +from bot.constants import Colours, Tokens  log = logging.getLogger(__name__) +API_URL = "http://api.giphy.com/v1/gifs/random" +  class SpookyGif(commands.Cog):      """A cog to fetch a random spooky gif from the web!""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @commands.command(name="spookygif", aliases=("sgif", "scarygif"))      async def spookygif(self, ctx: commands.Context) -> None:          """Fetches a random gif from the GIPHY API and responds with it."""          async with ctx.typing(): -            async with aiohttp.ClientSession() as session: -                params = {'api_key': Tokens.giphy, 'tag': 'halloween', 'rating': 'g'} -                # Make a GET request to the Giphy API to get a random halloween gif. -                async with session.get('http://api.giphy.com/v1/gifs/random', params=params) as resp: -                    data = await resp.json() -                url = data['data']['image_url'] +            params = {"api_key": Tokens.giphy, "tag": "halloween", "rating": "g"} +            # Make a GET request to the Giphy API to get a random halloween gif. +            async with self.bot.http_session.get(API_URL, params=params) as resp: +                data = await resp.json() +            url = data["data"]["image_url"] -                embed = discord.Embed(colour=0x9b59b6) -                embed.title = "A spooooky gif!" -                embed.set_image(url=url) +            embed = discord.Embed(title="A spooooky gif!", colour=Colours.purple) +            embed.set_image(url=url)          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: +def setup(bot: Bot) -> None:      """Spooky GIF Cog load."""      bot.add_cog(SpookyGif(bot)) diff --git a/bot/exts/halloween/spookynamerate.py b/bot/exts/halloween/spookynamerate.py index e2950343..3d6d95fa 100644 --- a/bot/exts/halloween/spookynamerate.py +++ b/bot/exts/halloween/spookynamerate.py @@ -6,14 +6,15 @@ from datetime import datetime, timedelta  from logging import getLogger  from os import getenv  from pathlib import Path -from typing import Dict, Union +from typing import Union  from async_rediscache import RedisCache  from discord import Embed, Reaction, TextChannel, User  from discord.colour import Colour  from discord.ext import tasks -from discord.ext.commands import Bot, Cog, Context, group +from discord.ext.commands import Cog, Context, group +from bot.bot import Bot  from bot.constants import Channels, Client, Colours, Month  from bot.utils.decorators import InMonthCheckFailure @@ -34,7 +35,7 @@ ADDED_MESSAGES = [  ]  PING = "<@{id}>" -EMOJI_MESSAGE = "\n".join([f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items()]) +EMOJI_MESSAGE = "\n".join(f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items())  HELP_MESSAGE_DICT = {      "title": "Spooky Name Rate",      "description": f"Help for the `{Client.prefix}spookynamerate` command", @@ -64,6 +65,11 @@ HELP_MESSAGE_DICT = {      ],  } +# The names are from https://www.mockaroo.com/ +NAMES = json.loads(Path("bot/resources/halloween/spookynamerate_names.json").read_text("utf8")) +FIRST_NAMES = NAMES["first_names"] +LAST_NAMES = NAMES["last_names"] +  class SpookyNameRate(Cog):      """ @@ -80,21 +86,13 @@ class SpookyNameRate(Cog):      # The data cache stores small information such as the current name that is going on and whether it is the first time      # the bot is running      data = RedisCache() -    debug = getenv('SPOOKYNAMERATE_DEBUG', False)  # Enable if you do not want to limit the commands to October or if +    debug = getenv("SPOOKYNAMERATE_DEBUG", False)  # Enable if you do not want to limit the commands to October or if      # you do not want to wait till 12 UTC. Note: if debug is enabled and you run `.cogs reload spookynamerate`, it      # will automatically start the scoring and announcing the result (without waiting for 12, so do not expect it to.).      # Also, it won't wait for the two hours (when the poll closes).      def __init__(self, bot: Bot) -> None:          self.bot = bot - -        names_data = self.load_json( -            Path("bot", "resources", "halloween", "spookynamerate_names.json") -        ) -        self.first_names = names_data["first_names"] -        self.last_names = names_data["last_names"] -        # the names are from https://www.mockaroo.com/ -          self.name = None          self.bot.loop.create_task(self.load_vars()) @@ -116,7 +114,7 @@ class SpookyNameRate(Cog):          """Get help on the Spooky Name Rate game."""          await ctx.send(embed=Embed.from_dict(HELP_MESSAGE_DICT)) -    @spooky_name_rate.command(name="list", aliases=["all", "entries"]) +    @spooky_name_rate.command(name="list", aliases=("all", "entries"))      async def list_entries(self, ctx: Context) -> None:          """Send all the entries up till now in a single embed."""          await ctx.send(embed=await self.get_responses_list(final=False)) @@ -133,18 +131,16 @@ class SpookyNameRate(Cog):              "add an entry."          ) -    @spooky_name_rate.command(name="add", aliases=["register"]) +    @spooky_name_rate.command(name="add", aliases=("register",))      async def add_name(self, ctx: Context, *, name: str) -> None:          """Use this command to add/register your spookified name."""          if self.poll: -            logger.info(f"{ctx.message.author} tried to add a name, but the poll had already started.") +            logger.info(f"{ctx.author} tried to add a name, but the poll had already started.")              await ctx.send("Sorry, the poll has started! You can try and participate in the next round though!")              return -        message = ctx.message -          for data in (json.loads(user_data) for _, user_data in await self.messages.items()): -            if data["author"] == message.author.id: +            if data["author"] == ctx.author.id:                  await ctx.send(                      "But you have already added an entry! Type "                      f"`{self.bot.command_prefix}spookynamerate " @@ -156,14 +152,14 @@ class SpookyNameRate(Cog):                  await ctx.send("TOO LATE. Someone has already added this name.")                  return -        msg = await (await self.get_channel()).send(f"{message.author.mention} added the name {name!r}!") +        msg = await (await self.get_channel()).send(f"{ctx.author.mention} added the name {name!r}!")          await self.messages.set(              msg.id,              json.dumps(                  {                      "name": name, -                    "author": message.author.id, +                    "author": ctx.author.id,                      "score": 0,                  }              ), @@ -172,7 +168,7 @@ class SpookyNameRate(Cog):          for emoji in EMOJIS_VAL:              await msg.add_reaction(emoji) -        logger.info(f"{message.author} added the name {name!r}") +        logger.info(f"{ctx.author} added the name {name!r}")      @spooky_name_rate.command(name="delete")      async def delete_name(self, ctx: Context) -> None: @@ -185,7 +181,7 @@ class SpookyNameRate(Cog):              if ctx.author.id == data["author"]:                  await self.messages.delete(message_id) -                await ctx.send(f'Name deleted successfully ({data["name"]!r})!') +                await ctx.send(f"Name deleted successfully ({data['name']!r})!")                  return          await ctx.send( @@ -303,7 +299,7 @@ class SpookyNameRate(Cog):                  await self.messages.clear()  # reset the messages          # send the next name -        self.name = f"{random.choice(self.first_names)} {random.choice(self.last_names)}" +        self.name = f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}"          await self.data.set("name", self.name)          await channel.send( @@ -370,12 +366,6 @@ class SpookyNameRate(Cog):          return channel      @staticmethod -    def load_json(file: Path) -> Dict[str, str]: -        """Loads a JSON file and returns its contents.""" -        with file.open("r", encoding="utf-8") as f: -            return json.load(f) - -    @staticmethod      def in_allowed_month() -> bool:          """Returns whether running in the limited month."""          if SpookyNameRate.debug: @@ -397,5 +387,5 @@ class SpookyNameRate(Cog):  def setup(bot: Bot) -> None: -    """Loads the SpookyNameRate Cog.""" +    """Load the SpookyNameRate Cog."""      bot.add_cog(SpookyNameRate(bot)) diff --git a/bot/exts/halloween/spookyrating.py b/bot/exts/halloween/spookyrating.py index 6f069f8c..105d2164 100644 --- a/bot/exts/halloween/spookyrating.py +++ b/bot/exts/halloween/spookyrating.py @@ -3,24 +3,24 @@ import json  import logging  import random  from pathlib import Path +from typing import Dict  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -with Path("bot/resources/halloween/spooky_rating.json").open(encoding="utf8") as file: -    SPOOKY_DATA = json.load(file) -    SPOOKY_DATA = sorted((int(key), value) for key, value in SPOOKY_DATA.items()) +data: Dict[str, Dict[str, str]] = json.loads(Path("bot/resources/halloween/spooky_rating.json").read_text("utf8")) +SPOOKY_DATA = sorted((int(key), value) for key, value in data.items())  class SpookyRating(commands.Cog):      """A cog for calculating one's spooky rating.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot +    def __init__(self):          self.local_random = random.Random()      @commands.command() @@ -46,21 +46,21 @@ class SpookyRating(commands.Cog):          _, data = SPOOKY_DATA[index]          embed = discord.Embed( -            title=data['title'], -            description=f'{who} scored {spooky_percent}%!', +            title=data["title"], +            description=f"{who} scored {spooky_percent}%!",              color=Colours.orange          )          embed.add_field( -            name='A whisper from Satan', -            value=data['text'] +            name="A whisper from Satan", +            value=data["text"]          )          embed.set_thumbnail( -            url=data['image'] +            url=data["image"]          )          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Spooky Rating Cog load.""" -    bot.add_cog(SpookyRating(bot)) +def setup(bot: Bot) -> None: +    """Load the Spooky Rating Cog.""" +    bot.add_cog(SpookyRating()) diff --git a/bot/exts/halloween/spookyreact.py b/bot/exts/halloween/spookyreact.py index b335df75..25e783f4 100644 --- a/bot/exts/halloween/spookyreact.py +++ b/bot/exts/halloween/spookyreact.py @@ -2,21 +2,22 @@ import logging  import re  import discord -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog +from bot.bot import Bot  from bot.constants import Month  from bot.utils.decorators import in_month  log = logging.getLogger(__name__)  SPOOKY_TRIGGERS = { -    'spooky': (r"\bspo{2,}ky\b", "\U0001F47B"), -    'skeleton': (r"\bskeleton\b", "\U0001F480"), -    'doot': (r"\bdo{2,}t\b", "\U0001F480"), -    'pumpkin': (r"\bpumpkin\b", "\U0001F383"), -    'halloween': (r"\bhalloween\b", "\U0001F383"), -    'jack-o-lantern': (r"\bjack-o-lantern\b", "\U0001F383"), -    'danger': (r"\bdanger\b", "\U00002620") +    "spooky": (r"\bspo{2,}ky\b", "\U0001F47B"), +    "skeleton": (r"\bskeleton\b", "\U0001F480"), +    "doot": (r"\bdo{2,}t\b", "\U0001F480"), +    "pumpkin": (r"\bpumpkin\b", "\U0001F383"), +    "halloween": (r"\bhalloween\b", "\U0001F383"), +    "jack-o-lantern": (r"\bjack-o-lantern\b", "\U0001F383"), +    "danger": (r"\bdanger\b", "\U00002620")  } @@ -28,20 +29,20 @@ class SpookyReact(Cog):      @in_month(Month.OCTOBER)      @Cog.listener() -    async def on_message(self, ctx: discord.Message) -> None: +    async def on_message(self, message: discord.Message) -> None:          """Triggered when the bot sees a message in October.""" -        for trigger in SPOOKY_TRIGGERS.keys(): -            trigger_test = re.search(SPOOKY_TRIGGERS[trigger][0], ctx.content.lower()) +        for name, trigger in SPOOKY_TRIGGERS.items(): +            trigger_test = re.search(trigger[0], message.content.lower())              if trigger_test:                  # Check message for bot replies and/or command invocations                  # Short circuit if they're found, logging is handled in _short_circuit_check -                if await self._short_circuit_check(ctx): +                if await self._short_circuit_check(message):                      return                  else: -                    await ctx.add_reaction(SPOOKY_TRIGGERS[trigger][1]) -                    logging.info(f"Added '{trigger}' reaction to message ID: {ctx.id}") +                    await message.add_reaction(trigger[1]) +                    log.info(f"Added {name!r} reaction to message ID: {message.id}") -    async def _short_circuit_check(self, ctx: discord.Message) -> bool: +    async def _short_circuit_check(self, message: discord.Message) -> bool:          """          Short-circuit helper check. @@ -50,20 +51,20 @@ class SpookyReact(Cog):            * prefix is not None          """          # Check for self reaction -        if ctx.author == self.bot.user: -            logging.debug(f"Ignoring reactions on self message. Message ID: {ctx.id}") +        if message.author == self.bot.user: +            log.debug(f"Ignoring reactions on self message. Message ID: {message.id}")              return True          # Check for command invocation          # Because on_message doesn't give a full Context object, generate one first -        tmp_ctx = await self.bot.get_context(ctx) -        if tmp_ctx.prefix: -            logging.debug(f"Ignoring reactions on command invocation. Message ID: {ctx.id}") +        ctx = await self.bot.get_context(message) +        if ctx.prefix: +            log.debug(f"Ignoring reactions on command invocation. Message ID: {message.id}")              return True          return False  def setup(bot: Bot) -> None: -    """Spooky reaction Cog load.""" +    """Load the Spooky Reaction Cog."""      bot.add_cog(SpookyReact(bot)) diff --git a/bot/exts/halloween/timeleft.py b/bot/exts/halloween/timeleft.py index 47adb09b..e80025dc 100644 --- a/bot/exts/halloween/timeleft.py +++ b/bot/exts/halloween/timeleft.py @@ -4,15 +4,14 @@ from typing import Tuple  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__)  class TimeLeft(commands.Cog):      """A Cog that tells you how long left until Hacktober is over!""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      def in_hacktober(self) -> bool:          """Return True if the current time is within Hacktoberfest."""          _, end, start = self.load_date() @@ -64,6 +63,6 @@ class TimeLeft(commands.Cog):              ) -def setup(bot: commands.Bot) -> None: -    """Cog load.""" -    bot.add_cog(TimeLeft(bot)) +def setup(bot: Bot) -> None: +    """Load the Time Left Cog.""" +    bot.add_cog(TimeLeft()) diff --git a/bot/exts/internal_eval/__init__.py b/bot/exts/internal_eval/__init__.py new file mode 100644 index 00000000..695fa74d --- /dev/null +++ b/bot/exts/internal_eval/__init__.py @@ -0,0 +1,10 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: +    """Set up the Internal Eval extension.""" +    # Import the Cog at runtime to prevent side effects like defining +    # RedisCache instances too early. +    from ._internal_eval import InternalEval + +    bot.add_cog(InternalEval(bot)) diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py new file mode 100644 index 00000000..3a50b9f3 --- /dev/null +++ b/bot/exts/internal_eval/_helpers.py @@ -0,0 +1,249 @@ +import ast +import collections +import contextlib +import functools +import inspect +import io +import logging +import sys +import traceback +import types +import typing + + +log = logging.getLogger(__name__) + +# A type alias to annotate the tuples returned from `sys.exc_info()` +ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType] +Namespace = typing.Dict[str, typing.Any] + +# This will be used as an coroutine function wrapper for the code +# to be evaluated. The wrapper contains one `pass` statement which +# will be replaced with `ast` with the code that we want to have +# evaluated. +# The function redirects output and captures exceptions that were +# raised in the code we evaluate. The latter is used to provide a +# meaningful traceback to the end user. +EVAL_WRAPPER = """ +async def _eval_wrapper_function(): +    try: +        with contextlib.redirect_stdout(_eval_context.stdout): +            pass +        if '_value_last_expression' in locals(): +            if inspect.isawaitable(_value_last_expression): +                _value_last_expression = await _value_last_expression +            _eval_context._value_last_expression = _value_last_expression +        else: +            _eval_context._value_last_expression = None +    except Exception: +        _eval_context.exc_info = sys.exc_info() +    finally: +        _eval_context.locals = locals() +_eval_context.function = _eval_wrapper_function +""" +INTERNAL_EVAL_FRAMENAME = "<internal eval>" +EVAL_WRAPPER_FUNCTION_FRAMENAME = "_eval_wrapper_function" + + +def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: +    """Format an exception caught while evaluation code by inserting lines.""" +    exc_type, exc_value, tb = exc_info +    stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb)) +    code = code.split("\n") + +    output = ["Traceback (most recent call last):"] +    for frame in stack_summary: +        if frame.filename == INTERNAL_EVAL_FRAMENAME: +            line = code[frame.lineno - 1].lstrip() + +            if frame.name == EVAL_WRAPPER_FUNCTION_FRAMENAME: +                name = INTERNAL_EVAL_FRAMENAME +            else: +                name = frame.name +        else: +            line = frame.line +            name = frame.name + +        output.append( +            f'  File "{frame.filename}", line {frame.lineno}, in {name}\n' +            f"    {line}" +        ) + +    output.extend(traceback.format_exception_only(exc_type, exc_value)) +    return "\n".join(output) + + +class EvalContext: +    """ +    Represents the current `internal eval` context. + +    The context remembers names set during earlier runs of `internal eval`. To +    clear the context, use the `.internal clear` command. +    """ + +    def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None: +        self._locals = dict(local_vars) +        self.context_vars = dict(context_vars) + +        self.stdout = io.StringIO() +        self._value_last_expression = None +        self.exc_info = None +        self.code = "" +        self.function = None +        self.eval_tree = None + +    @property +    def dependencies(self) -> typing.Dict[str, typing.Any]: +        """ +        Return a mapping of the dependencies for the wrapper function. + +        By using a property descriptor, the mapping can't be accidentally +        mutated during evaluation. This ensures the dependencies are always +        available. +        """ +        return { +            "print": functools.partial(print, file=self.stdout), +            "contextlib": contextlib, +            "inspect": inspect, +            "sys": sys, +            "_eval_context": self, +            "_": self._value_last_expression, +        } + +    @property +    def locals(self) -> typing.Dict[str, typing.Any]: +        """Return a mapping of names->values needed for evaluation.""" +        return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)} + +    @locals.setter +    def locals(self, locals_: typing.Dict[str, typing.Any]) -> None: +        """Update the contextual mapping of names to values.""" +        log.trace(f"Updating {self._locals} with {locals_}") +        self._locals.update(locals_) + +    def prepare_eval(self, code: str) -> typing.Optional[str]: +        """Prepare an evaluation by processing the code and setting up the context.""" +        self.code = code + +        if not self.code: +            log.debug("No code was attached to the evaluation command") +            return "[No code detected]" + +        try: +            code_tree = ast.parse(code, filename=INTERNAL_EVAL_FRAMENAME) +        except SyntaxError: +            log.debug("Got a SyntaxError while parsing the eval code") +            return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) + +        log.trace("Parsing the AST to see if there's a trailing expression we need to capture") +        code_tree = CaptureLastExpression(code_tree).capture() + +        log.trace("Wrapping the AST in the AST of the wrapper coroutine") +        eval_tree = WrapEvalCodeTree(code_tree).wrap() + +        self.eval_tree = eval_tree +        return None + +    async def run_eval(self) -> Namespace: +        """Run the evaluation and return the updated locals.""" +        log.trace("Compiling the AST to bytecode using `exec` mode") +        compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec") + +        log.trace("Executing the compiled code with the desired namespace environment") +        exec(compiled_code, self.locals)  # noqa: B102,S102 + +        log.trace("Awaiting the created evaluation wrapper coroutine.") +        await self.function() + +        log.trace("Returning the updated captured locals.") +        return self._locals + +    def format_output(self) -> str: +        """Format the output of the most recent evaluation.""" +        output = [] + +        log.trace(f"Getting output from stdout `{id(self.stdout)}`") +        stdout_text = self.stdout.getvalue() +        if stdout_text: +            log.trace("Appending output captured from stdout/print") +            output.append(stdout_text) + +        if self._value_last_expression is not None: +            log.trace("Appending the output of a captured trialing expression") +            output.append(f"[Captured] {self._value_last_expression!r}") + +        if self.exc_info: +            log.trace("Appending exception information") +            output.append(format_internal_eval_exception(self.exc_info, self.code)) + +        log.trace(f"Generated output: {output!r}") +        return "\n".join(output) or "[No output]" + + +class WrapEvalCodeTree(ast.NodeTransformer): +    """Wraps the AST of eval code with the wrapper function.""" + +    def __init__(self, eval_code_tree: ast.AST, *args, **kwargs) -> None: +        super().__init__(*args, **kwargs) +        self.eval_code_tree = eval_code_tree + +        # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping +        self.wrapper = ast.parse(EVAL_WRAPPER, filename=INTERNAL_EVAL_FRAMENAME) + +    def wrap(self) -> ast.AST: +        """Wrap the tree of the code by the tree of the wrapper function.""" +        new_tree = self.visit(self.wrapper) +        return ast.fix_missing_locations(new_tree) + +    def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]:  # noqa: N802 +        """ +        Replace the `_ast.Pass` node in the wrapper function by the eval AST. + +        This method works on the assumption that there's a single `pass` +        statement in the wrapper function. +        """ +        return list(ast.iter_child_nodes(self.eval_code_tree)) + + +class CaptureLastExpression(ast.NodeTransformer): +    """Captures the return value from a loose expression.""" + +    def __init__(self, tree: ast.AST, *args, **kwargs) -> None: +        super().__init__(*args, **kwargs) +        self.tree = tree +        self.last_node = list(ast.iter_child_nodes(tree))[-1] + +    def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]:  # noqa: N802 +        """ +        Replace the Expr node that is last child node of Module with an assignment. + +        We use an assignment to capture the value of the last node, if it's a loose +        Expr node. Normally, the value of an Expr node is lost, meaning we don't get +        the output of such a last "loose" expression. By assigning it a name, we can +        retrieve it for our output. +        """ +        if node is not self.last_node: +            return node + +        log.trace("Found a trailing last expression in the evaluation code") + +        log.trace("Creating assignment statement with trailing expression as the right-hand side") +        right_hand_side = list(ast.iter_child_nodes(node))[0] + +        assignment = ast.Assign( +            targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], +            value=right_hand_side, +            lineno=node.lineno, +            col_offset=0, +        ) +        ast.fix_missing_locations(assignment) +        return assignment + +    def capture(self) -> ast.AST: +        """Capture the value of the last expression with an assignment.""" +        if not isinstance(self.last_node, ast.Expr): +            # We only have to replace a node if the very last node is an Expr node +            return self.tree + +        new_tree = self.visit(self.tree) +        return ast.fix_missing_locations(new_tree) diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py new file mode 100644 index 00000000..56bf5add --- /dev/null +++ b/bot/exts/internal_eval/_internal_eval.py @@ -0,0 +1,176 @@ +import logging +import re +import textwrap +import typing + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Roles +from bot.utils.decorators import with_role +from bot.utils.extensions import invoke_help_command +from ._helpers import EvalContext + +__all__ = ["InternalEval"] + +log = logging.getLogger(__name__) + +FORMATTED_CODE_REGEX = re.compile( +    r"(?P<delim>(?P<block>```)|``?)"        # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block +    r"(?(block)(?:(?P<lang>[a-z]+)\n)?)"    # if we're in a block, match optional language (only letters plus newline) +    r"(?:[ \t]*\n)*"                        # any blank (empty or tabs/spaces only) lines before the code +    r"(?P<code>.*?)"                        # extract all code inside the markup +    r"\s*"                                  # any more whitespace before the end of the code markup +    r"(?P=delim)",                          # match the exact same delimiter from the start again +    re.DOTALL | re.IGNORECASE               # "." also matches newlines, case insensitive +) + +RAW_CODE_REGEX = re.compile( +    r"^(?:[ \t]*\n)*"                       # any blank (empty or tabs/spaces only) lines before the code +    r"(?P<code>.*?)"                        # extract all the rest as code +    r"\s*$",                                # any trailing whitespace until the end of the string +    re.DOTALL                               # "." also matches newlines +) + + +class InternalEval(commands.Cog): +    """Top secret code evaluation for admins and owners.""" + +    def __init__(self, bot: Bot): +        self.bot = bot +        self.locals = {} + +    @staticmethod +    def shorten_output( +            output: str, +            max_length: int = 1900, +            placeholder: str = "\n[output truncated]" +    ) -> str: +        """ +        Shorten the `output` so it's shorter than `max_length`. + +        There are three tactics for this, tried in the following order: +        - Shorten the output on a line-by-line basis +        - Shorten the output on any whitespace character +        - Shorten the output solely on character count +        """ +        max_length = max_length - len(placeholder) + +        shortened_output = [] +        char_count = 0 +        for line in output.split("\n"): +            if char_count + len(line) > max_length: +                break +            shortened_output.append(line) +            char_count += len(line) + 1  # account for (possible) line ending + +        if shortened_output: +            shortened_output.append(placeholder) +            return "\n".join(shortened_output) + +        shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder) + +        if shortened_output.strip() == placeholder.strip(): +            # `textwrap` was unable to find whitespace to shorten on, so it has +            # reduced the output to just the placeholder. Let's shorten based on +            # characters instead. +            shortened_output = output[:max_length] + placeholder + +        return shortened_output + +    async def _upload_output(self, output: str) -> typing.Optional[str]: +        """Upload `internal eval` output to our pastebin and return the url.""" +        try: +            async with self.bot.http_session.post( +                "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True +            ) as resp: +                data = await resp.json() + +            if "key" in data: +                return f"https://paste.pythondiscord.com/{data['key']}" +        except Exception: +            # 400 (Bad Request) means there are too many characters +            log.exception("Failed to upload `internal eval` output to paste service!") + +    async def _send_output(self, ctx: commands.Context, output: str) -> None: +        """Send the `internal eval` output to the command invocation context.""" +        upload_message = "" +        if len(output) >= 1980: +            # The output is too long, let's truncate it for in-channel output and +            # upload the complete output to the paste service. +            url = await self._upload_output(output) + +            if url: +                upload_message = f"\nFull output here: {url}" +            else: +                upload_message = "\n:warning: Failed to upload full output!" + +            output = self.shorten_output(output) + +        await ctx.send(f"```py\n{output}\n```{upload_message}") + +    async def _eval(self, ctx: commands.Context, code: str) -> None: +        """Evaluate the `code` in the current evaluation context.""" +        context_vars = { +            "message": ctx.message, +            "author": ctx.author, +            "channel": ctx.channel, +            "guild": ctx.guild, +            "ctx": ctx, +            "self": self, +            "bot": self.bot, +            "discord": discord, +        } + +        eval_context = EvalContext(context_vars, self.locals) + +        log.trace("Preparing the evaluation by parsing the AST of the code") +        error = eval_context.prepare_eval(code) + +        if error: +            log.trace("The code can't be evaluated due to an error") +            await ctx.send(f"```py\n{error}\n```") +            return + +        log.trace("Evaluate the AST we've generated for the evaluation") +        new_locals = await eval_context.run_eval() + +        log.trace("Updating locals with those set during evaluation") +        self.locals.update(new_locals) + +        log.trace("Sending the formatted output back to the context") +        await self._send_output(ctx, eval_context.format_output()) + +    @commands.group(name="internal", aliases=("int",)) +    @with_role(Roles.admin) +    async def internal_group(self, ctx: commands.Context) -> None: +        """Internal commands. Top secret!""" +        if not ctx.invoked_subcommand: +            await invoke_help_command(ctx) + +    @internal_group.command(name="eval", aliases=("e",)) +    @with_role(Roles.admin) +    async def eval(self, ctx: commands.Context, *, code: str) -> None: +        """Run eval in a REPL-like format.""" +        if match := list(FORMATTED_CODE_REGEX.finditer(code)): +            blocks = [block for block in match if block.group("block")] + +            if len(blocks) > 1: +                code = "\n".join(block.group("code") for block in blocks) +            else: +                match = match[0] if len(blocks) == 0 else blocks[0] +                code, block, lang, delim = match.group("code", "block", "lang", "delim") + +        else: +            code = RAW_CODE_REGEX.fullmatch(code).group("code") + +        code = textwrap.dedent(code) +        await self._eval(ctx, code) + +    @internal_group.command(name="reset", aliases=("clear", "exit", "r", "c")) +    @with_role(Roles.admin) +    async def reset(self, ctx: commands.Context) -> None: +        """Reset the context and locals of the eval session.""" +        self.locals = {} +        await ctx.send("The evaluation context was reset.") diff --git a/bot/exts/pride/drag_queen_name.py b/bot/exts/pride/drag_queen_name.py index fca9750f..15ca6576 100644 --- a/bot/exts/pride/drag_queen_name.py +++ b/bot/exts/pride/drag_queen_name.py @@ -5,28 +5,22 @@ from pathlib import Path  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) +NAMES = json.loads(Path("bot/resources/pride/drag_queen_names.json").read_text("utf8")) +  class DragNames(commands.Cog):      """Gives a random drag queen name!""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -        self.names = self.load_names() - -    @staticmethod -    def load_names() -> list: -        """Loads a list of drag queen names.""" -        with open(Path("bot/resources/pride/drag_queen_names.json"), "r", encoding="utf8") as f: -            return json.load(f) - -    @commands.command(name="dragname", aliases=["dragqueenname", "queenme"]) +    @commands.command(name="dragname", aliases=("dragqueenname", "queenme"))      async def dragname(self, ctx: commands.Context) -> None:          """Sends a message with a drag queen name.""" -        await ctx.send(random.choice(self.names)) +        await ctx.send(random.choice(NAMES)) -def setup(bot: commands.Bot) -> None: -    """Cog loader for drag queen name generator.""" -    bot.add_cog(DragNames(bot)) +def setup(bot: Bot) -> None: +    """Load the Drag Names Cog.""" +    bot.add_cog(DragNames()) diff --git a/bot/exts/pride/pride_anthem.py b/bot/exts/pride/pride_anthem.py index 33cb2a9d..4650595a 100644 --- a/bot/exts/pride/pride_anthem.py +++ b/bot/exts/pride/pride_anthem.py @@ -2,20 +2,21 @@ import json  import logging  import random  from pathlib import Path +from typing import Optional  from discord.ext import commands +from bot.bot import Bot +  log = logging.getLogger(__name__) +VIDEOS = json.loads(Path("bot/resources/pride/anthems.json").read_text("utf8")) +  class PrideAnthem(commands.Cog):      """Embed a random youtube video for a gay anthem!""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -        self.anthems = self.load_vids() - -    def get_video(self, genre: str = None) -> dict: +    def get_video(self, genre: Optional[str] = None) -> dict:          """          Picks a random anthem from the list. @@ -25,20 +26,13 @@ class PrideAnthem(commands.Cog):          if not genre:              return random.choice(self.anthems)          else: -            songs = [song for song in self.anthems if genre.casefold() in song["genre"]] +            songs = [song for song in VIDEOS if genre.casefold() in song["genre"]]              try:                  return random.choice(songs)              except IndexError:                  log.info("No videos for that genre.") -    @staticmethod -    def load_vids() -> list: -        """Loads a list of videos from the resources folder as dictionaries.""" -        with open(Path("bot/resources/pride/anthems.json"), "r", encoding="utf8") as f: -            anthems = json.load(f) -        return anthems - -    @commands.command(name="prideanthem", aliases=["anthem", "pridesong"]) +    @commands.command(name="prideanthem", aliases=("anthem", "pridesong"))      async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None:          """          Sends a message with a video of a random pride anthem. @@ -52,6 +46,6 @@ class PrideAnthem(commands.Cog):              await ctx.send("I couldn't find a video, sorry!") -def setup(bot: commands.Bot) -> None: -    """Cog loader for pride anthem.""" -    bot.add_cog(PrideAnthem(bot)) +def setup(bot: Bot) -> None: +    """Load the Pride Anthem Cog.""" +    bot.add_cog(PrideAnthem()) diff --git a/bot/exts/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py deleted file mode 100644 index 2eade796..00000000 --- a/bot/exts/pride/pride_avatar.py +++ /dev/null @@ -1,177 +0,0 @@ -import logging -from io import BytesIO -from pathlib import Path -from typing import Tuple - -import aiohttp -import discord -from PIL import Image, ImageDraw, UnidentifiedImageError -from discord.ext.commands import Bot, Cog, Context, group - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -OPTIONS = { -    "agender": "agender", -    "androgyne": "androgyne", -    "androgynous": "androgyne", -    "aromantic": "aromantic", -    "aro": "aromantic", -    "ace": "asexual", -    "asexual": "asexual", -    "bigender": "bigender", -    "bisexual": "bisexual", -    "bi": "bisexual", -    "demiboy": "demiboy", -    "demigirl": "demigirl", -    "demi": "demisexual", -    "demisexual": "demisexual", -    "gay": "gay", -    "lgbt": "gay", -    "queer": "gay", -    "homosexual": "gay", -    "fluid": "genderfluid", -    "genderfluid": "genderfluid", -    "genderqueer": "genderqueer", -    "intersex": "intersex", -    "lesbian": "lesbian", -    "non-binary": "nonbinary", -    "enby": "nonbinary", -    "nb": "nonbinary", -    "nonbinary": "nonbinary", -    "omnisexual": "omnisexual", -    "omni": "omnisexual", -    "pansexual": "pansexual", -    "pan": "pansexual", -    "pangender": "pangender", -    "poly": "polysexual", -    "polysexual": "polysexual", -    "polyamory": "polyamory", -    "polyamorous": "polyamory", -    "transgender": "transgender", -    "trans": "transgender", -    "trigender": "trigender" -} - - -class PrideAvatar(Cog): -    """Put an LGBT spin on your avatar!""" - -    def __init__(self, bot: Bot): -        self.bot = bot - -    @staticmethod -    def crop_avatar(avatar: Image) -> Image: -        """This crops the avatar into a circle.""" -        mask = Image.new("L", avatar.size, 0) -        draw = ImageDraw.Draw(mask) -        draw.ellipse((0, 0) + avatar.size, fill=255) -        avatar.putalpha(mask) -        return avatar - -    @staticmethod -    def crop_ring(ring: Image, px: int) -> Image: -        """This crops the ring into a circle.""" -        mask = Image.new("L", ring.size, 0) -        draw = ImageDraw.Draw(mask) -        draw.ellipse((0, 0) + ring.size, fill=255) -        draw.ellipse((px, px, 1024-px, 1024-px), fill=0) -        ring.putalpha(mask) -        return ring - -    @staticmethod -    def process_options(option: str, pixels: int) -> Tuple[str, int, str]: -        """Does some shared preprocessing for the prideavatar commands.""" -        return option.lower(), max(0, min(512, pixels)), OPTIONS.get(option) - -    async def process_image(self, ctx: Context, image_bytes: bytes, pixels: int, flag: str, option: str) -> None: -        """Constructs the final image, embeds it, and sends it.""" -        try: -            avatar = Image.open(BytesIO(image_bytes)) -        except UnidentifiedImageError: -            return await ctx.send("Cannot identify image from provided URL") -        avatar = avatar.convert("RGBA").resize((1024, 1024)) - -        avatar = self.crop_avatar(avatar) - -        ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) -        ring = ring.convert("RGBA") -        ring = self.crop_ring(ring, pixels) - -        avatar.alpha_composite(ring, (0, 0)) -        bufferedio = BytesIO() -        avatar.save(bufferedio, format="PNG") -        bufferedio.seek(0) - -        file = discord.File(bufferedio, filename="pride_avatar.png")  # Creates file to be used in embed -        embed = discord.Embed( -            name="Your Lovely Pride Avatar", -            description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" -        ) -        embed.set_image(url="attachment://pride_avatar.png") -        embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) -        await ctx.send(file=file, embed=embed) - -    @group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) -    async def prideavatar(self, ctx: Context, option: str = "lgbt", pixels: int = 64) -> None: -        """ -        This surrounds an avatar with a border of a specified LGBT flag. - -        This defaults to the LGBT rainbow flag if none is given. -        The amount of pixels can be given which determines the thickness of the flag border. -        This has a maximum of 512px and defaults to a 64px border. -        The full image is 1024x1024. -        """ -        option, pixels, flag = self.process_options(option, pixels) -        if flag is None: -            return await ctx.send("I don't have that flag!") - -        async with ctx.typing(): -            image_bytes = await ctx.author.avatar_url.read() -            await self.process_image(ctx, image_bytes, pixels, flag, option) - -    @prideavatar.command() -    async def image(self, ctx: Context, url: str, option: str = "lgbt", pixels: int = 64) -> None: -        """ -        This surrounds the image specified by the URL with a border of a specified LGBT flag. - -        This defaults to the LGBT rainbow flag if none is given. -        The amount of pixels can be given which determines the thickness of the flag border. -        This has a maximum of 512px and defaults to a 64px border. -        The full image is 1024x1024. -        """ -        option, pixels, flag = self.process_options(option, pixels) -        if flag is None: -            return await ctx.send("I don't have that flag!") - -        async with ctx.typing(): -            async with aiohttp.ClientSession() as session: -                try: -                    response = await session.get(url) -                except aiohttp.client_exceptions.ClientConnectorError: -                    return await ctx.send("Cannot connect to provided URL!") -                except aiohttp.client_exceptions.InvalidURL: -                    return await ctx.send("Invalid URL!") -                if response.status != 200: -                    return await ctx.send("Bad response from provided URL!") -                image_bytes = await response.read() -                await self.process_image(ctx, image_bytes, pixels, flag, option) - -    @prideavatar.command() -    async def flags(self, ctx: Context) -> None: -        """This lists the flags that can be used with the prideavatar command.""" -        choices = sorted(set(OPTIONS.values())) -        options = "• " + "\n• ".join(choices) -        embed = discord.Embed( -            title="I have the following flags:", -            description=options, -            colour=Colours.soft_red -        ) - -        await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: -    """Cog load.""" -    bot.add_cog(PrideAvatar(bot)) diff --git a/bot/exts/pride/pride_facts.py b/bot/exts/pride/pride_facts.py index 5bd5d0ce..631e2e8b 100644 --- a/bot/exts/pride/pride_facts.py +++ b/bot/exts/pride/pride_facts.py @@ -15,7 +15,7 @@ from bot.utils.decorators import seasonal_task  log = logging.getLogger(__name__) -Sendable = Union[commands.Context, discord.TextChannel] +FACTS = json.loads(Path("bot/resources/pride/facts.json").read_text("utf8"))  class PrideFacts(commands.Cog): @@ -23,16 +23,8 @@ class PrideFacts(commands.Cog):      def __init__(self, bot: Bot):          self.bot = bot -        self.facts = self.load_facts() -          self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily()) -    @staticmethod -    def load_facts() -> dict: -        """Loads a dictionary of years mapping to lists of facts.""" -        with open(Path("bot/resources/pride/facts.json"), "r", encoding="utf8") as f: -            return json.load(f) -      @seasonal_task(Month.JUNE)      async def send_pride_fact_daily(self) -> None:          """Background task to post the daily pride fact every day.""" @@ -44,15 +36,15 @@ class PrideFacts(commands.Cog):      async def send_random_fact(self, ctx: commands.Context) -> None:          """Provides a fact from any previous day, or today."""          now = datetime.utcnow() -        previous_years_facts = (self.facts[x] for x in self.facts.keys() if int(x) < now.year) -        current_year_facts = self.facts.get(str(now.year), [])[:now.day] +        previous_years_facts = (y for x, y in FACTS.items() if int(x) < now.year) +        current_year_facts = FACTS.get(str(now.year), [])[:now.day]          previous_facts = current_year_facts + [x for y in previous_years_facts for x in y]          try:              await ctx.send(embed=self.make_embed(random.choice(previous_facts)))          except IndexError:              await ctx.send("No facts available") -    async def send_select_fact(self, target: Sendable, _date: Union[str, datetime]) -> None: +    async def send_select_fact(self, target: discord.abc.Messageable, _date: Union[str, datetime]) -> None:          """Provides the fact for the specified day, if the day is today, or is in the past."""          now = datetime.utcnow()          if isinstance(_date, str): @@ -75,8 +67,8 @@ class PrideFacts(commands.Cog):          else:              await target.send("The fact for the selected day is not yet available.") -    @commands.command(name="pridefact", aliases=["pridefacts"]) -    async def pridefact(self, ctx: commands.Context) -> None: +    @commands.command(name="pridefact", aliases=("pridefacts",)) +    async def pridefact(self, ctx: commands.Context, option: str = None) -> None:          """          Sends a message with a pride fact of the day. @@ -85,15 +77,15 @@ class PrideFacts(commands.Cog):          If a date is given as an argument, and the date is in the past, the fact from that day          will be provided.          """ -        message_body = ctx.message.content[len(ctx.invoked_with) + 2:] -        if message_body == "": +        if not option:              await self.send_select_fact(ctx, datetime.utcnow()) -        elif message_body.lower().startswith("rand"): +        elif option.lower().startswith("rand"):              await self.send_random_fact(ctx)          else: -            await self.send_select_fact(ctx, message_body) +            await self.send_select_fact(ctx, option) -    def make_embed(self, fact: str) -> discord.Embed: +    @staticmethod +    def make_embed(fact: str) -> discord.Embed:          """Makes a nice embed for the fact to be sent."""          return discord.Embed(              colour=Colours.pink, @@ -103,5 +95,5 @@ class PrideFacts(commands.Cog):  def setup(bot: Bot) -> None: -    """Cog loader for pride facts.""" +    """Load the Pride Facts Cog."""      bot.add_cog(PrideFacts(bot)) diff --git a/bot/exts/valentines/be_my_valentine.py b/bot/exts/valentines/be_my_valentine.py index 09591cf8..8b522a72 100644 --- a/bot/exts/valentines/be_my_valentine.py +++ b/bot/exts/valentines/be_my_valentine.py @@ -1,13 +1,13 @@  import logging  import random -from json import load +from json import loads  from pathlib import Path  from typing import Tuple  import discord  from discord.ext import commands -from discord.ext.commands.cooldowns import BucketType +from bot.bot import Bot  from bot.constants import Channels, Colours, Lovefest, Month  from bot.utils.decorators import in_month  from bot.utils.extensions import invoke_help_command @@ -20,7 +20,7 @@ HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_hea  class BeMyValentine(commands.Cog):      """A cog that sends Valentines to other users!""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot          self.valentines = self.load_json() @@ -28,9 +28,7 @@ class BeMyValentine(commands.Cog):      def load_json() -> dict:          """Load Valentines messages from the static resources."""          p = Path("bot/resources/valentines/bemyvalentine_valentines.json") -        with p.open(encoding="utf8") as json_data: -            valentines = load(json_data) -            return valentines +        return loads(p.read_text("utf8"))      @in_month(Month.FEBRUARY)      @commands.group(name="lovefest") @@ -50,8 +48,8 @@ class BeMyValentine(commands.Cog):      async def add_role(self, ctx: commands.Context) -> None:          """Adds the lovefest role."""          user = ctx.author -        role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) -        if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: +        role = ctx.guild.get_role(Lovefest.role_id) +        if role not in ctx.author.roles:              await user.add_roles(role)              await ctx.send("The Lovefest role has been added !")          else: @@ -61,15 +59,15 @@ class BeMyValentine(commands.Cog):      async def remove_role(self, ctx: commands.Context) -> None:          """Removes the lovefest role."""          user = ctx.author -        role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) -        if Lovefest.role_id not in [role.id for role in ctx.message.author.roles]: +        role = ctx.guild.get_role(Lovefest.role_id) +        if role not in ctx.author.roles:              await ctx.send("You dont have the lovefest role.")          else:              await user.remove_roles(role) -            await ctx.send("The lovefest role has been successfully removed !") +            await ctx.send("The lovefest role has been successfully removed!") -    @commands.cooldown(1, 1800, BucketType.user) -    @commands.group(name='bemyvalentine', invoke_without_command=True) +    @commands.cooldown(1, 1800, commands.BucketType.user) +    @commands.group(name="bemyvalentine", invoke_without_command=True)      async def send_valentine(          self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None      ) -> None: @@ -101,14 +99,14 @@ class BeMyValentine(commands.Cog):          valentine, title = self.valentine_check(valentine_type)          embed = discord.Embed( -            title=f'{emoji_1} {title} {user.display_name} {emoji_2}', -            description=f'{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**', +            title=f"{emoji_1} {title} {user.display_name} {emoji_2}", +            description=f"{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**",              color=Colours.pink          )          await channel.send(user.mention, embed=embed) -    @commands.cooldown(1, 1800, BucketType.user) -    @send_valentine.command(name='secret') +    @commands.cooldown(1, 1800, commands.BucketType.user) +    @send_valentine.command(name="secret")      async def anonymous(          self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None      ) -> None: @@ -136,8 +134,8 @@ class BeMyValentine(commands.Cog):          valentine, title = self.valentine_check(valentine_type)          embed = discord.Embed( -            title=f'{emoji_1}{title} {user.display_name}{emoji_2}', -            description=f'{valentine} \n **{emoji_2}From anonymous{emoji_1}**', +            title=f"{emoji_1}{title} {user.display_name}{emoji_2}", +            description=f"{valentine} \n **{emoji_2}From anonymous{emoji_1}**",              color=Colours.pink          )          await ctx.message.delete() @@ -151,21 +149,17 @@ class BeMyValentine(commands.Cog):      def valentine_check(self, valentine_type: str) -> Tuple[str, str]:          """Return the appropriate Valentine type & title based on the invoking user's input."""          if valentine_type is None: -            valentine, title = self.random_valentine() +            return self.random_valentine() -        elif valentine_type.lower() in ['p', 'poem']: -            valentine = self.valentine_poem() -            title = 'A poem dedicated to' +        elif valentine_type.lower() in ["p", "poem"]: +            return self.valentine_poem(), "A poem dedicated to" -        elif valentine_type.lower() in ['c', 'compliment']: -            valentine = self.valentine_compliment() -            title = 'A compliment for' +        elif valentine_type.lower() in ["c", "compliment"]: +            return self.valentine_compliment(), "A compliment for"          else:              # in this case, the user decides to type his own valentine. -            valentine = valentine_type -            title = 'A message for' -        return valentine, title +            return valentine_type, "A message for"      @staticmethod      def random_emoji() -> Tuple[str, str]: @@ -176,26 +170,24 @@ class BeMyValentine(commands.Cog):      def random_valentine(self) -> Tuple[str, str]:          """Grabs a random poem or a compliment (any message).""" -        valentine_poem = random.choice(self.valentines['valentine_poems']) -        valentine_compliment = random.choice(self.valentines['valentine_compliments']) +        valentine_poem = random.choice(self.valentines["valentine_poems"]) +        valentine_compliment = random.choice(self.valentines["valentine_compliments"])          random_valentine = random.choice([valentine_compliment, valentine_poem])          if random_valentine == valentine_poem: -            title = 'A poem dedicated to' +            title = "A poem dedicated to"          else: -            title = 'A compliment for ' +            title = "A compliment for "          return random_valentine, title      def valentine_poem(self) -> str:          """Grabs a random poem.""" -        valentine_poem = random.choice(self.valentines['valentine_poems']) -        return valentine_poem +        return random.choice(self.valentines["valentine_poems"])      def valentine_compliment(self) -> str:          """Grabs a random compliment.""" -        valentine_compliment = random.choice(self.valentines['valentine_compliments']) -        return valentine_compliment +        return random.choice(self.valentines["valentine_compliments"]) -def setup(bot: commands.Bot) -> None: -    """Be my Valentine Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Be my Valentine Cog."""      bot.add_cog(BeMyValentine(bot)) diff --git a/bot/exts/valentines/lovecalculator.py b/bot/exts/valentines/lovecalculator.py index 966acc82..b10b7bca 100644 --- a/bot/exts/valentines/lovecalculator.py +++ b/bot/exts/valentines/lovecalculator.py @@ -11,20 +11,18 @@ from discord import Member  from discord.ext import commands  from discord.ext.commands import BadArgument, Cog, clean_content +from bot.bot import Bot +  log = logging.getLogger(__name__) -with Path("bot/resources/valentines/love_matches.json").open(encoding="utf8") as file: -    LOVE_DATA = json.load(file) -    LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items()) +LOVE_DATA = json.loads(Path("bot/resources/valentines/love_matches.json").read_text("utf8")) +LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items())  class LoveCalculator(Cog):      """A cog for calculating the love between two people.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    @commands.command(aliases=('love_calculator', 'love_calc')) +    @commands.command(aliases=("love_calculator", "love_calc"))      @commands.cooldown(rate=1, per=5, type=commands.BucketType.user)      async def love(self, ctx: commands.Context, who: Union[Member, str], whom: Union[Member, str] = None) -> None:          """ @@ -62,7 +60,7 @@ class LoveCalculator(Cog):          # Make sure user didn't provide something silly such as 10 spaces          if not (who and whom): -            raise BadArgument('Arguments be non-empty strings.') +            raise BadArgument("Arguments must be non-empty strings.")          # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary)          # @@ -79,20 +77,20 @@ class LoveCalculator(Cog):          # We only need the dict, so we can ditch the first element          _, data = LOVE_DATA[index] -        status = random.choice(data['titles']) +        status = random.choice(data["titles"])          embed = discord.Embed(              title=status, -            description=f'{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b', +            description=f"{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b",              color=discord.Color.dark_magenta()          )          embed.add_field( -            name='A letter from Dr. Love:', -            value=data['text'] +            name="A letter from Dr. Love:", +            value=data["text"]          )          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Love calculator Cog load.""" -    bot.add_cog(LoveCalculator(bot)) +def setup(bot: Bot) -> None: +    """Load the Love calculator Cog.""" +    bot.add_cog(LoveCalculator()) diff --git a/bot/exts/valentines/movie_generator.py b/bot/exts/valentines/movie_generator.py index 4df9e0d5..0fc5edb4 100644 --- a/bot/exts/valentines/movie_generator.py +++ b/bot/exts/valentines/movie_generator.py @@ -6,6 +6,8 @@ from urllib import parse  import discord  from discord.ext import commands +from bot.bot import Bot +  TMDB_API_KEY = environ.get("TMDB_API_KEY")  log = logging.getLogger(__name__) @@ -14,7 +16,7 @@ log = logging.getLogger(__name__)  class RomanceMovieFinder(commands.Cog):      """A Cog that returns a random romance movie suggestion to a user.""" -    def __init__(self, bot: commands.Bot): +    def __init__(self, bot: Bot):          self.bot = bot      @commands.command(name="romancemovie") @@ -52,13 +54,15 @@ class RomanceMovieFinder(commands.Cog):                  embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")                  await ctx.send(embed=embed)              except KeyError: -                warning_message = "A KeyError was raised while fetching information on the movie. The API service" \ -                                  " could be unavailable or the API key could be set incorrectly." +                warning_message = ( +                    "A KeyError was raised while fetching information on the movie. The API service" +                    " could be unavailable or the API key could be set incorrectly." +                )                  embed = discord.Embed(title=warning_message)                  log.warning(warning_message)                  await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Romance movie Cog load.""" +def setup(bot: Bot) -> None: +    """Load the Romance movie Cog."""      bot.add_cog(RomanceMovieFinder(bot)) diff --git a/bot/exts/valentines/myvalenstate.py b/bot/exts/valentines/myvalenstate.py index 01801847..52a61011 100644 --- a/bot/exts/valentines/myvalenstate.py +++ b/bot/exts/valentines/myvalenstate.py @@ -7,20 +7,17 @@ from random import choice  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -with open(Path("bot/resources/valentines/valenstates.json"), "r", encoding="utf8") as file: -    STATES = json.load(file) +STATES = json.loads(Path("bot/resources/valentines/valenstates.json").read_text("utf8"))  class MyValenstate(commands.Cog):      """A Cog to find your most likely Valentine's vacation destination.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      def levenshtein(self, source: str, goal: str) -> int:          """Calculates the Levenshtein Distance between source and goal."""          if len(source) < len(goal): @@ -46,12 +43,12 @@ class MyValenstate(commands.Cog):          """Find the vacation spot(s) with the most matching characters to the invoking user."""          eq_chars = collections.defaultdict(int)          if name is None: -            author = ctx.message.author.name.lower().replace(' ', '') +            author = ctx.author.name.lower().replace(" ", "")          else: -            author = name.lower().replace(' ', '') +            author = name.lower().replace(" ", "")          for state in STATES.keys(): -            lower_state = state.lower().replace(' ', '') +            lower_state = state.lower().replace(" ", "")              eq_chars[state] = self.levenshtein(author, lower_state)          matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())] @@ -60,27 +57,26 @@ class MyValenstate(commands.Cog):          embed_title = "But there are more!"          if len(matches) > 1: -            leftovers = f"{', '.join(matches[:len(matches)-2])}, and {matches[len(matches)-1]}" +            leftovers = f"{', '.join(matches[:-2])}, and {matches[-1]}"              embed_text = f"You have {len(matches)} more matches, these being {leftovers}."          elif len(matches) == 1:              embed_title = "But there's another one!" -            leftovers = str(matches) -            embed_text = f"You have another match, this being {leftovers}." +            embed_text = f"You have another match, this being {matches[0]}."          else:              embed_title = "You have a true match!"              embed_text = "This state is your true Valenstate! There are no states that would suit" \                           " you better"          embed = discord.Embed( -            title=f'Your Valenstate is {valenstate} \u2764', -            description=f'{STATES[valenstate]["text"]}', +            title=f"Your Valenstate is {valenstate} \u2764", +            description=STATES[valenstate]["text"],              colour=Colours.pink          )          embed.add_field(name=embed_title, value=embed_text)          embed.set_image(url=STATES[valenstate]["flag"]) -        await ctx.channel.send(embed=embed) +        await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Valenstate Cog load.""" -    bot.add_cog(MyValenstate(bot)) +def setup(bot: Bot) -> None: +    """Load the Valenstate Cog.""" +    bot.add_cog(MyValenstate()) diff --git a/bot/exts/valentines/pickuplines.py b/bot/exts/valentines/pickuplines.py index 74c7e68b..00741a72 100644 --- a/bot/exts/valentines/pickuplines.py +++ b/bot/exts/valentines/pickuplines.py @@ -1,25 +1,22 @@  import logging  import random -from json import load +from json import loads  from pathlib import Path  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -with open(Path("bot/resources/valentines/pickup_lines.json"), "r", encoding="utf8") as f: -    pickup_lines = load(f) +PICKUP_LINES = loads(Path("bot/resources/valentines/pickup_lines.json").read_text("utf8"))  class PickupLine(commands.Cog):      """A cog that gives random cheesy pickup lines.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @commands.command()      async def pickupline(self, ctx: commands.Context) -> None:          """ @@ -27,18 +24,18 @@ class PickupLine(commands.Cog):          Note that most of them are very cheesy.          """ -        random_line = random.choice(pickup_lines['lines']) +        random_line = random.choice(PICKUP_LINES["lines"])          embed = discord.Embed( -            title=':cheese: Your pickup line :cheese:', -            description=random_line['line'], +            title=":cheese: Your pickup line :cheese:", +            description=random_line["line"],              color=Colours.pink          )          embed.set_thumbnail( -            url=random_line.get('image', pickup_lines['placeholder']) +            url=random_line.get("image", PICKUP_LINES["placeholder"])          )          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Pickup lines Cog load.""" -    bot.add_cog(PickupLine(bot)) +def setup(bot: Bot) -> None: +    """Load the Pickup lines Cog.""" +    bot.add_cog(PickupLine()) diff --git a/bot/exts/valentines/savethedate.py b/bot/exts/valentines/savethedate.py index ac38d279..ffe559d6 100644 --- a/bot/exts/valentines/savethedate.py +++ b/bot/exts/valentines/savethedate.py @@ -1,31 +1,28 @@  import logging  import random -from json import load +from json import loads  from pathlib import Path  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__)  HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] -with open(Path("bot/resources/valentines/date_ideas.json"), "r", encoding="utf8") as f: -    VALENTINES_DATES = load(f) +VALENTINES_DATES = loads(Path("bot/resources/valentines/date_ideas.json").read_text("utf8"))  class SaveTheDate(commands.Cog):      """A cog that gives random suggestion for a Valentine's date.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot -      @commands.command()      async def savethedate(self, ctx: commands.Context) -> None:          """Gives you ideas for what to do on a date with your valentine.""" -        random_date = random.choice(VALENTINES_DATES['ideas']) +        random_date = random.choice(VALENTINES_DATES["ideas"])          emoji_1 = random.choice(HEART_EMOJIS)          emoji_2 = random.choice(HEART_EMOJIS)          embed = discord.Embed( @@ -36,6 +33,6 @@ class SaveTheDate(commands.Cog):          await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Save the date Cog Load.""" -    bot.add_cog(SaveTheDate(bot)) +def setup(bot: Bot) -> None: +    """Load the Save the date Cog.""" +    bot.add_cog(SaveTheDate()) diff --git a/bot/exts/valentines/valentine_zodiac.py b/bot/exts/valentines/valentine_zodiac.py index 2696999f..d862ee63 100644 --- a/bot/exts/valentines/valentine_zodiac.py +++ b/bot/exts/valentines/valentine_zodiac.py @@ -9,19 +9,19 @@ from typing import Tuple, Union  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -LETTER_EMOJI = ':love_letter:' +LETTER_EMOJI = ":love_letter:"  HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"]  class ValentineZodiac(commands.Cog):      """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot +    def __init__(self):          self.zodiacs, self.zodiac_fact = self.load_comp_json()      @staticmethod @@ -29,14 +29,14 @@ class ValentineZodiac(commands.Cog):          """Load zodiac compatibility from static JSON resource."""          explanation_file = Path("bot/resources/valentines/zodiac_explanation.json")          compatibility_file = Path("bot/resources/valentines/zodiac_compatibility.json") -        with explanation_file.open(encoding="utf8") as json_data: -            zodiac_fact = json.load(json_data) -            for zodiac_data in zodiac_fact.values(): -                zodiac_data['start_at'] = datetime.fromisoformat(zodiac_data['start_at']) -                zodiac_data['end_at'] = datetime.fromisoformat(zodiac_data['end_at']) -        with compatibility_file.open(encoding="utf8") as json_data: -            zodiacs = json.load(json_data) +        zodiac_fact = json.loads(explanation_file.read_text("utf8")) + +        for zodiac_data in zodiac_fact.values(): +            zodiac_data["start_at"] = datetime.fromisoformat(zodiac_data["start_at"]) +            zodiac_data["end_at"] = datetime.fromisoformat(zodiac_data["end_at"]) + +        zodiacs = json.loads(compatibility_file.read_text("utf8"))          return zodiacs, zodiac_fact @@ -62,10 +62,10 @@ class ValentineZodiac(commands.Cog):              log.trace("Making zodiac embed.")              embed.title = f"__{zodiac}__"              embed.description = self.zodiac_fact[zodiac]["About"] -            embed.add_field(name='__Motto__', value=self.zodiac_fact[zodiac]["Motto"], inline=False) -            embed.add_field(name='__Strengths__', value=self.zodiac_fact[zodiac]["Strengths"], inline=False) -            embed.add_field(name='__Weaknesses__', value=self.zodiac_fact[zodiac]["Weaknesses"], inline=False) -            embed.add_field(name='__Full form__', value=self.zodiac_fact[zodiac]["full_form"], inline=False) +            embed.add_field(name="__Motto__", value=self.zodiac_fact[zodiac]["Motto"], inline=False) +            embed.add_field(name="__Strengths__", value=self.zodiac_fact[zodiac]["Strengths"], inline=False) +            embed.add_field(name="__Weaknesses__", value=self.zodiac_fact[zodiac]["Weaknesses"], inline=False) +            embed.add_field(name="__Full form__", value=self.zodiac_fact[zodiac]["full_form"], inline=False)              embed.set_thumbnail(url=self.zodiac_fact[zodiac]["url"])          else:              embed = self.generate_invalidname_embed(zodiac) @@ -79,7 +79,7 @@ class ValentineZodiac(commands.Cog):                  log.trace("Zodiac name sent.")                  return zodiac_name -    @commands.group(name='zodiac', invoke_without_command=True) +    @commands.group(name="zodiac", invoke_without_command=True)      async def zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:          """Provides information about zodiac sign by taking zodiac sign name as input."""          final_embed = self.zodiac_build_embed(zodiac_sign) @@ -93,9 +93,9 @@ class ValentineZodiac(commands.Cog):              month = month.capitalize()              try:                  month = list(calendar.month_abbr).index(month[:3]) -                log.trace('Valid month name entered by user') +                log.trace("Valid month name entered by user")              except ValueError: -                log.info('Invalid month name entered by user') +                log.info("Invalid month name entered by user")                  await ctx.send(f"Sorry, but `{month}` is not a valid month name.")                  return          if (month == 1 and 1 <= date <= 19) or (month == 12 and 22 <= date <= 31): @@ -109,14 +109,14 @@ class ValentineZodiac(commands.Cog):                  final_embed = discord.Embed()                  final_embed.color = Colours.soft_red                  final_embed.description = f"Zodiac sign could not be found because.\n```{e}```" -                log.info(f'Error in "zodiac date" command:\n{e}.') +                log.info(f"Error in 'zodiac date' command:\n{e}.")              else:                  final_embed = self.zodiac_build_embed(zodiac_sign_based_on_date)          await ctx.send(embed=final_embed)          log.trace("Embed from date successfully sent.") -    @zodiac.command(name="partnerzodiac", aliases=['partner']) +    @zodiac.command(name="partnerzodiac", aliases=("partner",))      async def partner_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:          """Provides a random counter compatible zodiac sign to the given user's zodiac sign."""          embed = discord.Embed() @@ -128,12 +128,12 @@ class ValentineZodiac(commands.Cog):              emoji2 = random.choice(HEART_EMOJIS)              embed.title = "Zodiac Compatibility"              embed.description = ( -                f'{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac["Zodiac"]}\n' -                f'{emoji2}Compatibility meter : {compatible_zodiac["compatibility_score"]}{emoji2}' +                f"{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac['Zodiac']}\n" +                f"{emoji2}Compatibility meter : {compatible_zodiac['compatibility_score']}{emoji2}"              )              embed.add_field( -                name=f'A letter from Dr.Zodiac {LETTER_EMOJI}', -                value=compatible_zodiac['description'] +                name=f"A letter from Dr.Zodiac {LETTER_EMOJI}", +                value=compatible_zodiac["description"]              )          else:              embed = self.generate_invalidname_embed(zodiac_sign) @@ -141,6 +141,6 @@ class ValentineZodiac(commands.Cog):          log.trace("Embed from date successfully sent.") -def setup(bot: commands.Bot) -> None: -    """Valentine zodiac Cog load.""" -    bot.add_cog(ValentineZodiac(bot)) +def setup(bot: Bot) -> None: +    """Load the Valentine zodiac Cog.""" +    bot.add_cog(ValentineZodiac()) diff --git a/bot/exts/valentines/whoisvalentine.py b/bot/exts/valentines/whoisvalentine.py index 0ff9186c..211b1f27 100644 --- a/bot/exts/valentines/whoisvalentine.py +++ b/bot/exts/valentines/whoisvalentine.py @@ -6,47 +6,44 @@ from random import choice  import discord  from discord.ext import commands +from bot.bot import Bot  from bot.constants import Colours  log = logging.getLogger(__name__) -with open(Path("bot/resources/valentines/valentine_facts.json"), "r", encoding="utf8") as file: -    FACTS = json.load(file) +FACTS = json.loads(Path("bot/resources/valentines/valentine_facts.json").read_text("utf8"))  class ValentineFacts(commands.Cog):      """A Cog for displaying facts about Saint Valentine.""" -    def __init__(self, bot: commands.Bot): -        self.bot = bot - -    @commands.command(aliases=('whoisvalentine', 'saint_valentine')) +    @commands.command(aliases=("whoisvalentine", "saint_valentine"))      async def who_is_valentine(self, ctx: commands.Context) -> None:          """Displays info about Saint Valentine."""          embed = discord.Embed(              title="Who is Saint Valentine?", -            description=FACTS['whois'], +            description=FACTS["whois"],              color=Colours.pink          )          embed.set_thumbnail( -            url='https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_' -                'facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg' +            url="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_" +                "facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg"          ) -        await ctx.channel.send(embed=embed) +        await ctx.send(embed=embed)      @commands.command()      async def valentine_fact(self, ctx: commands.Context) -> None:          """Shows a random fact about Valentine's Day."""          embed = discord.Embed( -            title=choice(FACTS['titles']), -            description=choice(FACTS['text']), +            title=choice(FACTS["titles"]), +            description=choice(FACTS["text"]),              color=Colours.pink          ) -        await ctx.channel.send(embed=embed) +        await ctx.send(embed=embed) -def setup(bot: commands.Bot) -> None: -    """Who is Valentine Cog load.""" -    bot.add_cog(ValentineFacts(bot)) +def setup(bot: Bot) -> None: +    """Load the Who is Valentine Cog.""" +    bot.add_cog(ValentineFacts()) diff --git a/bot/group.py b/bot/group.py new file mode 100644 index 00000000..a7bc59b7 --- /dev/null +++ b/bot/group.py @@ -0,0 +1,18 @@ +from discord.ext import commands + + +class Group(commands.Group): +    """ +    A `discord.ext.commands.Group` subclass which supports root aliases. + +    A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as +    top-level groups rather than being aliases of the command's group. It's stored as an attribute +    also named `root_aliases`. +    """ + +    def __init__(self, *args, **kwargs): +        super().__init__(*args, **kwargs) +        self.root_aliases = kwargs.get("root_aliases", []) + +        if not isinstance(self.root_aliases, (list, tuple)): +            raise TypeError("Root aliases of a group must be a list or a tuple of strings.") diff --git a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3Binary files differ deleted file mode 100644 index 495f2bd1..00000000 --- a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3Binary files differ deleted file mode 100644 index 538feabc..00000000 --- a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3Binary files differ deleted file mode 100644 index 17f66698..00000000 --- a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3Binary files differ deleted file mode 100644 index 5670657c..00000000 --- a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3Binary files differ deleted file mode 100644 index 42f9e9fd..00000000 --- a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3Binary files differ deleted file mode 100644 index 1cdb0f4d..00000000 --- a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3Binary files differ deleted file mode 100644 index 89150d57..00000000 --- a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3Binary files differ deleted file mode 100644 index b5f85f8d..00000000 --- a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3Binary files differ deleted file mode 100644 index d141f68e..00000000 --- a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3Binary files differ deleted file mode 100644 index a0614b53..00000000 --- a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3Binary files differ deleted file mode 100644 index 38374316..00000000 --- a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3Binary files differ deleted file mode 100644 index f769d9d8..00000000 --- a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3Binary files differ deleted file mode 100644 index 8b04f0f5..00000000 --- a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3Binary files differ deleted file mode 100644 index 964d685e..00000000 --- a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3Binary files differ deleted file mode 100644 index 9e643773..00000000 --- a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3Binary files differ deleted file mode 100644 index ad99cf76..00000000 --- a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 +++ /dev/null diff --git a/bot/resources/halloween/spookysounds/sources.txt b/bot/resources/halloween/spookysounds/sources.txt deleted file mode 100644 index 7df03c2e..00000000 --- a/bot/resources/halloween/spookysounds/sources.txt +++ /dev/null @@ -1,41 +0,0 @@ -Female_Monster_Growls_ -Male_Zombie_Roar_ -Monster_Alien_Growl_Calm_ -Monster_Alien_Grunt_Hiss_ -https://www.youtube.com/audiolibrary/soundeffects - -413315__inspectorj__something-evil-approaches-a -https://freesound.org/people/InspectorJ/sounds/413315/ - -133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08 -https://freesound.org/people/klankbeeld/sounds/133674/ - -35716__analogchill__scream -https://freesound.org/people/analogchill/sounds/35716/ - -249686__cylon8472__cthulhu-growl -https://freesound.org/people/cylon8472/sounds/249686/ - -126113__klankbeeld__laugh -https://freesound.org/people/klankbeeld/sounds/126113/ - -14570__oscillator__ghost-fx -https://freesound.org/people/oscillator/sounds/14570/ - -60571__gabemiller74__breathofdeath -https://freesound.org/people/gabemiller74/sounds/60571/ - -168650__0xmusex0__doorcreak -https://freesound.org/people/0XMUSEX0/sounds/168650/ - -193812__geoneo0__four-voices-whispering-6 -https://freesound.org/people/geoneo0/sounds/193812/ - -109710__tomlija__horror-gate -https://freesound.org/people/Tomlija/sounds/109710/ - -171078__klankbeeld__horror-scream-woman-long -https://freesound.org/people/klankbeeld/sounds/171078/ - -237282__devilfish101__frantic-violin-screech -https://freesound.org/people/devilfish101/sounds/237282/ diff --git a/bot/resources/pride/gender_options.json b/bot/resources/pride/gender_options.json new file mode 100644 index 00000000..062742fb --- /dev/null +++ b/bot/resources/pride/gender_options.json @@ -0,0 +1,41 @@ +{ +    "agender": "agender", +    "androgyne": "androgyne", +    "androgynous": "androgyne", +    "aromantic": "aromantic", +    "aro": "aromantic", +    "ace": "asexual", +    "asexual": "asexual", +    "bigender": "bigender", +    "bisexual": "bisexual", +    "bi": "bisexual", +    "demiboy": "demiboy", +    "demigirl": "demigirl", +    "demi": "demisexual", +    "demisexual": "demisexual", +    "gay": "gay", +    "lgbt": "gay", +    "queer": "gay", +    "homosexual": "gay", +    "fluid": "genderfluid", +    "genderfluid": "genderfluid", +    "genderqueer": "genderqueer", +    "intersex": "intersex", +    "lesbian": "lesbian", +    "non-binary": "nonbinary", +    "enby": "nonbinary", +    "nb": "nonbinary", +    "nonbinary": "nonbinary", +    "omnisexual": "omnisexual", +    "omni": "omnisexual", +    "pansexual": "pansexual", +    "pan": "pansexual", +    "pangender": "pangender", +    "poly": "polysexual", +    "polysexual": "polysexual", +    "polyamory": "polyamory", +    "polyamorous": "polyamory", +    "transgender": "transgender", +    "trans": "transgender", +    "trigender": "trigender" +} diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 35ef0a7b..bef12d25 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -3,7 +3,7 @@ import contextlib  import re  import string  from datetime import datetime -from typing import Iterable, List +from typing import Iterable, List, Optional  import discord  from discord.ext.commands import BadArgument, Context @@ -31,8 +31,13 @@ def resolve_current_month() -> Month:  async def disambiguate( -        ctx: Context, entries: List[str], *, timeout: float = 30, -        entries_per_page: int = 20, empty: bool = False, embed: discord.Embed = None +    ctx: Context, +    entries: List[str], +    *, +    timeout: float = 30, +    entries_per_page: int = 20, +    empty: bool = False, +    embed: Optional[discord.Embed] = None  ) -> str:      """      Has the user choose between multiple entries in case one could not be chosen automatically. @@ -43,25 +48,29 @@ async def disambiguate(      or if the user makes an invalid choice.      """      if len(entries) == 0: -        raise BadArgument('No matches found.') +        raise BadArgument("No matches found.")      if len(entries) == 1:          return entries[0] -    choices = (f'{index}: {entry}' for index, entry in enumerate(entries, start=1)) +    choices = (f"{index}: {entry}" for index, entry in enumerate(entries, start=1))      def check(message: discord.Message) -> bool: -        return (message.content.isdigit() -                and message.author == ctx.author -                and message.channel == ctx.channel) +        return ( +            message.content.isdecimal() +            and message.author == ctx.author +            and message.channel == ctx.channel +        )      try:          if embed is None:              embed = discord.Embed() -        coro1 = ctx.bot.wait_for('message', check=check, timeout=timeout) -        coro2 = LinePaginator.paginate(choices, ctx, embed=embed, max_lines=entries_per_page, -                                       empty=empty, max_size=6000, timeout=9000) +        coro1 = ctx.bot.wait_for("message", check=check, timeout=timeout) +        coro2 = LinePaginator.paginate( +            choices, ctx, embed=embed, max_lines=entries_per_page, +            empty=empty, max_size=6000, timeout=9000 +        )          # wait_for timeout will go to except instead of the wait_for thing as I expected          futures = [asyncio.ensure_future(coro1), asyncio.ensure_future(coro2)] @@ -74,7 +83,7 @@ async def disambiguate(          if result is None:              for coro in pending:                  coro.cancel() -            raise BadArgument('Canceled.') +            raise BadArgument("Canceled.")          # Pagination was not initiated, only one page          if result.author == ctx.bot.user: @@ -85,19 +94,19 @@ async def disambiguate(          for coro in pending:              coro.cancel()      except asyncio.TimeoutError: -        raise BadArgument('Timed out.') +        raise BadArgument("Timed out.") -    # Guaranteed to not error because of isdigit() in check +    # Guaranteed to not error because of isdecimal() in check      index = int(result.content)      try:          return entries[index - 1]      except IndexError: -        raise BadArgument('Invalid choice.') +        raise BadArgument("Invalid choice.")  def replace_many( -        sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False +    sentence: str, replacements: dict, *, ignore_case: bool = False, match_case: bool = False  ) -> str:      """      Replaces multiple substrings in a string given a mapping of strings. @@ -139,7 +148,7 @@ def replace_many(              return replacement          # Clean punctuation from word so string methods work -        cleaned_word = word.translate(str.maketrans('', '', string.punctuation)) +        cleaned_word = word.translate(str.maketrans("", "", string.punctuation))          if cleaned_word.isupper():              return replacement.upper()          elif cleaned_word[0].isupper(): diff --git a/bot/utils/checks.py b/bot/utils/checks.py index 9dd4dde0..c06b6870 100644 --- a/bot/utils/checks.py +++ b/bot/utils/checks.py @@ -92,8 +92,10 @@ def in_whitelist_check(  def with_role_check(ctx: Context, *role_ids: int) -> bool:      """Returns True if the user has any one of the roles in role_ids."""      if not ctx.guild:  # Return False in a DM -        log.trace(f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " -                  "This command is restricted by the with_role decorator. Rejecting request.") +        log.trace( +            f"{ctx.author} tried to use the '{ctx.command.name}'command from a DM. " +            "This command is restricted by the with_role decorator. Rejecting request." +        )          return False      for role in ctx.author.roles: @@ -101,22 +103,28 @@ def with_role_check(ctx: Context, *role_ids: int) -> bool:              log.trace(f"{ctx.author} has the '{role.name}' role, and passes the check.")              return True -    log.trace(f"{ctx.author} does not have the required role to use " -              f"the '{ctx.command.name}' command, so the request is rejected.") +    log.trace( +        f"{ctx.author} does not have the required role to use " +        f"the '{ctx.command.name}' command, so the request is rejected." +    )      return False  def without_role_check(ctx: Context, *role_ids: int) -> bool:      """Returns True if the user does not have any of the roles in role_ids."""      if not ctx.guild:  # Return False in a DM -        log.trace(f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " -                  "This command is restricted by the without_role decorator. Rejecting request.") +        log.trace( +            f"{ctx.author} tried to use the '{ctx.command.name}' command from a DM. " +            "This command is restricted by the without_role decorator. Rejecting request." +        )          return False      author_roles = [role.id for role in ctx.author.roles]      check = all(role not in author_roles for role in role_ids) -    log.trace(f"{ctx.author} tried to call the '{ctx.command.name}' command. " -              f"The result of the without_role check was {check}.") +    log.trace( +        f"{ctx.author} tried to call the '{ctx.command.name}' command. " +        f"The result of the without_role check was {check}." +    )      return check @@ -154,8 +162,10 @@ def cooldown_with_role_bypass(rate: int, per: float, type: BucketType = BucketTy          #          # If the `before_invoke` detail is ever a problem then I can quickly just swap over.          if not isinstance(command, Command): -            raise TypeError('Decorator `cooldown_with_role_bypass` must be applied after the command decorator. ' -                            'This means it has to be above the command decorator in the code.') +            raise TypeError( +                "Decorator `cooldown_with_role_bypass` must be applied after the command decorator. " +                "This means it has to be above the command decorator in the code." +            )          command._before_invoke = predicate diff --git a/bot/utils/converters.py b/bot/utils/converters.py index 228714c9..fe2c980c 100644 --- a/bot/utils/converters.py +++ b/bot/utils/converters.py @@ -1,11 +1,14 @@ +from datetime import datetime +from typing import Tuple, Union +  import discord -from discord.ext.commands.converter import MessageConverter +from discord.ext import commands -class WrappedMessageConverter(MessageConverter): +class WrappedMessageConverter(commands.MessageConverter):      """A converter that handles embed-suppressed links like <http://example.com>.""" -    async def convert(self, ctx: discord.ext.commands.Context, argument: str) -> discord.Message: +    async def convert(self, ctx: commands.Context, argument: str) -> discord.Message:          """Wrap the commands.MessageConverter to handle <> delimited message links."""          # It's possible to wrap a message in [<>] as well, and it's supported because its easy          if argument.startswith("[") and argument.endswith("]"): @@ -14,3 +17,99 @@ class WrappedMessageConverter(MessageConverter):              argument = argument[1:-1]          return await super().convert(ctx, argument) + + +class CoordinateConverter(commands.Converter): +    """Converter for Coordinates.""" + +    @staticmethod +    async def convert(ctx: commands.Context, coordinate: str) -> Tuple[int, int]: +        """Take in a coordinate string and turn it into an (x, y) tuple.""" +        if len(coordinate) not in (2, 3): +            raise commands.BadArgument("Invalid co-ordinate provided.") + +        coordinate = coordinate.lower() +        if coordinate[0].isalpha(): +            digit = coordinate[1:] +            letter = coordinate[0] +        else: +            digit = coordinate[:-1] +            letter = coordinate[-1] + +        if not digit.isdecimal(): +            raise commands.BadArgument + +        x = ord(letter) - ord("a") +        y = int(digit) - 1 + +        if (not 0 <= x <= 9) or (not 0 <= y <= 9): +            raise commands.BadArgument +        return x, y + + +SourceType = Union[commands.Command, commands.Cog] + + +class SourceConverter(commands.Converter): +    """Convert an argument into a command or cog.""" + +    @staticmethod +    async def convert(ctx: commands.Context, argument: str) -> SourceType: +        """Convert argument into source object.""" +        cog = ctx.bot.get_cog(argument) +        if cog: +            return cog + +        cmd = ctx.bot.get_command(argument) +        if cmd: +            return cmd + +        raise commands.BadArgument( +            f"Unable to convert `{argument}` to valid command or Cog." +        ) + + +class DateConverter(commands.Converter): +    """Parse SOL or earth date (in format YYYY-MM-DD) into `int` or `datetime`. When invalid input, raise error.""" + +    @staticmethod +    async def convert(ctx: commands.Context, argument: str) -> Union[int, datetime]: +        """Parse date (SOL or earth) into `datetime` or `int`. When invalid value, raise error.""" +        if argument.isdecimal(): +            return int(argument) +        try: +            date = datetime.strptime(argument, "%Y-%m-%d") +        except ValueError: +            raise commands.BadArgument( +                f"Can't convert `{argument}` to `datetime` in format `YYYY-MM-DD` or `int` in SOL." +            ) +        return date + + +class Subreddit(commands.Converter): +    """Forces a string to begin with "r/" and checks if it's a valid subreddit.""" + +    @staticmethod +    async def convert(ctx: commands.Context, sub: str) -> str: +        """ +        Force sub to begin with "r/" and check if it's a valid subreddit. + +        If sub is a valid subreddit, return it prepended with "r/" +        """ +        sub = sub.lower() + +        if not sub.startswith("r/"): +            sub = f"r/{sub}" + +        resp = await ctx.bot.http_session.get( +            "https://www.reddit.com/subreddits/search.json", +            params={"q": sub} +        ) + +        json = await resp.json() +        if not json["data"]["children"]: +            raise commands.BadArgument( +                f"The subreddit `{sub}` either doesn't exist, or it has no posts." +            ) + +        return sub diff --git a/bot/utils/decorators.py b/bot/utils/decorators.py index 60066dc4..c0783144 100644 --- a/bot/utils/decorators.py +++ b/bot/utils/decorators.py @@ -269,7 +269,7 @@ def whitelist_check(**default_kwargs: t.Container[int]) -> t.Callable[[Context],                  channels.update(channel.id for channel in category.text_channels)          if channels: -            channels_str = ', '.join(f"<#{c_id}>" for c_id in channels) +            channels_str = ", ".join(f"<#{c_id}>" for c_id in channels)              message = f"Sorry, but you may only use this command within {channels_str}."          else:              message = "Sorry, but you may not use this command." diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index 2b1c1b31..9e080759 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -1,4 +1,4 @@  class UserNotPlayingError(Exception): -    """Will raised when user try to use game commands when not playing.""" +    """Raised when users try to use game commands when they are not playing."""      pass diff --git a/bot/utils/extensions.py b/bot/utils/extensions.py index 459588a1..cd491c4b 100644 --- a/bot/utils/extensions.py +++ b/bot/utils/extensions.py @@ -35,8 +35,8 @@ def walk_extensions() -> Iterator[str]:  async def invoke_help_command(ctx: Context) -> None:      """Invoke the help command or default help command if help extensions is not loaded.""" -    if 'bot.exts.evergreen.help' in ctx.bot.extensions: -        help_command = ctx.bot.get_command('help') +    if "bot.exts.evergreen.help" in ctx.bot.extensions: +        help_command = ctx.bot.get_command("help")          await ctx.invoke(help_command, ctx.command.qualified_name)          return      await ctx.send_help(ctx.command) diff --git a/bot/utils/halloween/spookifications.py b/bot/utils/halloween/spookifications.py index 11f69850..f69dd6fd 100644 --- a/bot/utils/halloween/spookifications.py +++ b/bot/utils/halloween/spookifications.py @@ -13,16 +13,16 @@ def inversion(im: Image) -> Image:      Returns an inverted image when supplied with an Image object.      """ -    im = im.convert('RGB') +    im = im.convert("RGB")      inv = ImageOps.invert(im)      return inv  def pentagram(im: Image) -> Image:      """Adds pentagram to the image.""" -    im = im.convert('RGB') +    im = im.convert("RGB")      wt, ht = im.size -    penta = Image.open('bot/resources/halloween/bloody-pentagram.png') +    penta = Image.open("bot/resources/halloween/bloody-pentagram.png")      penta = penta.resize((wt, ht))      im.paste(penta, (0, 0), penta)      return im @@ -35,9 +35,9 @@ def bat(im: Image) -> Image:      The bat silhoutte is of a size at least one-fifths that of the original image and may be rotated      up to 90 degrees anti-clockwise.      """ -    im = im.convert('RGB') +    im = im.convert("RGB")      wt, ht = im.size -    bat = Image.open('bot/resources/halloween/bat-clipart.png') +    bat = Image.open("bot/resources/halloween/bat-clipart.png")      bat_size = randint(wt//10, wt//7)      rot = randint(0, 90)      bat = bat.resize((bat_size, bat_size)) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 00000000..74c2ccd0 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,8 @@ +import re + + +def suppress_links(message: str) -> str: +    """Accepts a message that may contain links, suppresses them, and returns them.""" +    for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)): +        message = message.replace(link, f"<{link}>") +    return message diff --git a/bot/utils/messages.py b/bot/utils/messages.py new file mode 100644 index 00000000..a6c035f9 --- /dev/null +++ b/bot/utils/messages.py @@ -0,0 +1,19 @@ +import re +from typing import Optional + + +def sub_clyde(username: Optional[str]) -> Optional[str]: +    """ +    Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string. + +    Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. +    Return None only if `username` is None. +    """ +    def replace_e(match: re.Match) -> str: +        char = "е" if match[2] == "e" else "Е" +        return match[1] + char + +    if username: +        return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) +    else: +        return username  # Empty string or None diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py index a4d0cc56..742281d7 100644 --- a/bot/utils/pagination.py +++ b/bot/utils/pagination.py @@ -4,6 +4,7 @@ from typing import Iterable, List, Optional, Tuple  from discord import Embed, Member, Reaction  from discord.abc import User +from discord.embeds import EmptyEmbed  from discord.ext.commands import Context, Paginator  from bot.constants import Emojis @@ -26,7 +27,7 @@ class EmptyPaginatorEmbed(Exception):  class LinePaginator(Paginator):      """A class that aids in paginating code blocks for Discord messages.""" -    def __init__(self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None): +    def __init__(self, prefix: str = "```", suffix: str = "```", max_size: int = 2000, max_lines: int = None):          """          Overrides the Paginator.__init__ from inside discord.ext.commands. @@ -44,7 +45,7 @@ class LinePaginator(Paginator):          self._count = len(prefix) + 1  # prefix + newline          self._pages = [] -    def add_line(self, line: str = '', *, empty: bool = False) -> None: +    def add_line(self, line: str = "", *, empty: bool = False) -> None:          """          Adds a line to the current page. @@ -56,7 +57,7 @@ class LinePaginator(Paginator):          If `empty` is True, an empty line will be placed after the a given `line`.          """          if len(line) > self.max_size - len(self.prefix) - 2: -            raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) +            raise RuntimeError("Line exceeds maximum page size %s" % (self.max_size - len(self.prefix) - 2))          if self.max_lines is not None:              if self._linecount >= self.max_lines: @@ -71,7 +72,7 @@ class LinePaginator(Paginator):          self._current_page.append(line)          if empty: -            self._current_page.append('') +            self._current_page.append("")              self._count += 1      @classmethod @@ -79,7 +80,7 @@ class LinePaginator(Paginator):                         prefix: str = "", suffix: str = "", max_lines: Optional[int] = None,                         max_size: int = 500, empty: bool = True, restrict_to_user: User = None,                         timeout: int = 300, footer_text: str = None, url: str = None, -                       exception_on_empty_embed: bool = False): +                       exception_on_empty_embed: bool = False) -> None:          """          Use a paginator and set of reactions to provide pagination over a set of lines. @@ -157,7 +158,8 @@ class LinePaginator(Paginator):                  log.trace(f"Setting embed url to '{url}'")              log.debug("There's less than two pages, so we won't paginate - sending single page on its own") -            return await ctx.send(embed=embed) +            await ctx.send(embed=embed) +            return          else:              if footer_text:                  embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -282,7 +284,7 @@ class ImagePaginator(Paginator):          self.images = []          self._pages = [] -    def add_line(self, line: str = '', *, empty: bool = False) -> None: +    def add_line(self, line: str = "", *, empty: bool = False) -> None:          """          Adds a line to each page, usually just 1 line in this context. @@ -302,7 +304,7 @@ class ImagePaginator(Paginator):      @classmethod      async def paginate(cls, pages: List[Tuple[str, str]], ctx: Context, embed: Embed,                         prefix: str = "", suffix: str = "", timeout: int = 300, -                       exception_on_empty_embed: bool = False): +                       exception_on_empty_embed: bool = False) -> None:          """          Use a paginator and set of reactions to provide pagination over a set of title/image pairs. @@ -352,7 +354,8 @@ class ImagePaginator(Paginator):              embed.set_image(url=image)          if len(paginator.pages) <= 1: -            return await ctx.send(embed=embed) +            await ctx.send(embed=embed) +            return          embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")          message = await ctx.send(embed=embed) @@ -417,9 +420,8 @@ class ImagePaginator(Paginator):              await message.edit(embed=embed)              embed.description = paginator.pages[current_page] -            image = paginator.images[current_page] -            if image: -                embed.set_image(url=image) +            image = paginator.images[current_page] or EmptyEmbed +            embed.set_image(url=image)              embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")              log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") | 
