diff options
Diffstat (limited to 'bot/exts/evergreen')
41 files changed, 1891 insertions, 908 deletions
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)) |