From f63af81bb9f1613ca2b4e00c3f12978a02a86983 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 18 Feb 2021 19:23:04 +0000 Subject: Move gender options to a resource file --- bot/resources/pride/gender_options.json | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 bot/resources/pride/gender_options.json (limited to 'bot') diff --git a/bot/resources/pride/gender_options.json b/bot/resources/pride/gender_options.json new file mode 100644 index 00000000..062742fb --- /dev/null +++ b/bot/resources/pride/gender_options.json @@ -0,0 +1,41 @@ +{ + "agender": "agender", + "androgyne": "androgyne", + "androgynous": "androgyne", + "aromantic": "aromantic", + "aro": "aromantic", + "ace": "asexual", + "asexual": "asexual", + "bigender": "bigender", + "bisexual": "bisexual", + "bi": "bisexual", + "demiboy": "demiboy", + "demigirl": "demigirl", + "demi": "demisexual", + "demisexual": "demisexual", + "gay": "gay", + "lgbt": "gay", + "queer": "gay", + "homosexual": "gay", + "fluid": "genderfluid", + "genderfluid": "genderfluid", + "genderqueer": "genderqueer", + "intersex": "intersex", + "lesbian": "lesbian", + "non-binary": "nonbinary", + "enby": "nonbinary", + "nb": "nonbinary", + "nonbinary": "nonbinary", + "omnisexual": "omnisexual", + "omni": "omnisexual", + "pansexual": "pansexual", + "pan": "pansexual", + "pangender": "pangender", + "poly": "polysexual", + "polysexual": "polysexual", + "polyamory": "polyamory", + "polyamorous": "polyamory", + "transgender": "transgender", + "trans": "transgender", + "trigender": "trigender" +} -- cgit v1.2.3 From dd59719df6a36f6cea98faba262fa171ce3289f0 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 18 Feb 2021 19:25:47 +0000 Subject: Merge all avatar cogs into one. Also includes some in_executor and max_concurrency code. --- bot/exts/easter/avatar_easterifier.py | 128 ------------- bot/exts/evergreen/8bitify.py | 54 ------ bot/exts/evergreen/pfp_modify.py | 337 ++++++++++++++++++++++++++++++++++ bot/exts/halloween/spookyavatar.py | 52 ------ bot/exts/pride/pride_avatar.py | 177 ------------------ 5 files changed, 337 insertions(+), 411 deletions(-) delete mode 100644 bot/exts/easter/avatar_easterifier.py delete mode 100644 bot/exts/evergreen/8bitify.py create mode 100644 bot/exts/evergreen/pfp_modify.py delete mode 100644 bot/exts/halloween/spookyavatar.py delete mode 100644 bot/exts/pride/pride_avatar.py (limited to 'bot') diff --git a/bot/exts/easter/avatar_easterifier.py b/bot/exts/easter/avatar_easterifier.py deleted file mode 100644 index 8e8a3500..00000000 --- a/bot/exts/easter/avatar_easterifier.py +++ /dev/null @@ -1,128 +0,0 @@ -import asyncio -import logging -from io import BytesIO -from pathlib import Path -from typing import Tuple, Union - -import discord -from PIL import Image -from PIL.ImageOps import posterize -from discord.ext import commands - -log = logging.getLogger(__name__) - -COLOURS = [ - (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), - (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), - (135, 206, 235), (0, 204, 204), (64, 224, 208) -] # Pastel colours - Easter-like - - -class AvatarEasterifier(commands.Cog): - """Put an Easter spin on your avatar or image!""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - @staticmethod - def closest(x: Tuple[int, int, int]) -> Tuple[int, int, int]: - """ - Finds the closest easter colour to a given pixel. - - Returns a merge between the original colour and the closest colour - """ - r1, g1, b1 = x - - def distance(point: Tuple[int, int, int]) -> Tuple[int, int, int]: - """Finds the difference between a pastel colour and the original pixel colour.""" - r2, g2, b2 = point - return ((r1 - r2)**2 + (g1 - g2)**2 + (b1 - b2)**2) - - closest_colours = sorted(COLOURS, key=lambda point: distance(point)) - r2, g2, b2 = closest_colours[0] - r = (r1 + r2) // 2 - g = (g1 + g2) // 2 - b = (b1 + b2) // 2 - - return (r, g, b) - - @commands.command(pass_context=True, aliases=["easterify"]) - async def avatareasterify(self, ctx: commands.Context, *colours: Union[discord.Colour, str]) -> None: - """ - This "Easterifies" the user's avatar. - - Given colours will produce a personalised egg in the corner, similar to the egg_decorate command. - If colours are not given, a nice little chocolate bunny will sit in the corner. - Colours are split by spaces, unless you wrap the colour name in double quotes. - Discord colour names, HTML colour names, XKCD colour names and hex values are accepted. - """ - async def send(*args, **kwargs) -> str: - """ - This replaces the original ctx.send. - - When invoking the egg decorating command, the egg itself doesn't print to to the channel. - Returns the message content so that if any errors occur, the error message can be output. - """ - if args: - return args[0] - - async with ctx.typing(): - - # Grabs image of avatar - image_bytes = await ctx.author.avatar_url_as(size=256).read() - - old = Image.open(BytesIO(image_bytes)) - old = old.convert("RGBA") - - # Grabs alpha channel since posterize can't be used with an RGBA image. - alpha = old.getchannel("A").getdata() - old = old.convert("RGB") - old = posterize(old, 6) - - data = old.getdata() - setted_data = set(data) - new_d = {} - - for x in setted_data: - new_d[x] = self.closest(x) - await asyncio.sleep(0) # Ensures discord doesn't break in the background. - new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] - - im = Image.new("RGBA", old.size) - im.putdata(new_data) - - if colours: - send_message = ctx.send - ctx.send = send # Assigns ctx.send to a fake send - egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) - if isinstance(egg, str): # When an error message occurs in eggdecorate. - return await send_message(egg) - - ratio = 64 / egg.height - egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) - egg = egg.convert("RGBA") - im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. - ctx.send = send_message # Reassigns ctx.send - else: - bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) - im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. - - bufferedio = BytesIO() - im.save(bufferedio, format="PNG") - - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed - embed = discord.Embed( - name="Your Lovely Easterified Avatar", - description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" - ) - embed.set_image(url="attachment://easterified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Avatar Easterifier Cog load.""" - bot.add_cog(AvatarEasterifier(bot)) diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py deleted file mode 100644 index 54e68f80..00000000 --- a/bot/exts/evergreen/8bitify.py +++ /dev/null @@ -1,54 +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(): - image_bytes = await ctx.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/pfp_modify.py b/bot/exts/evergreen/pfp_modify.py new file mode 100644 index 00000000..f356d2f6 --- /dev/null +++ b/bot/exts/evergreen/pfp_modify.py @@ -0,0 +1,337 @@ +import asyncio +import json +import logging +import os +import typing as t +from concurrent.futures import ThreadPoolExecutor +from io import BytesIO +from pathlib import Path + +import aiofiles +import aiohttp +import discord +from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError +from discord.ext import commands + +from bot.constants import Colours +from bot.utils.halloween import spookifications + +log = logging.getLogger(__name__) + +EASTER_COLOURS = [ + (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), + (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), + (135, 206, 235), (0, 204, 204), (64, 224, 208) +] # Pastel colours - Easter-like + +_EXECUTOR = ThreadPoolExecutor(10) + + +async def in_thread(func: t.Callable, *args) -> asyncio.Future: + """Allows non-async functions to work in async functions.""" + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_EXECUTOR, func, *args) + + +class PfpModify(commands.Cog): + """Various commands for users to change their own profile picture.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.bot.loop.create_task(self.init_cog()) + + async def init_cog(self) -> None: + """Initial load from resources asynchronously.""" + async with aiofiles.open('bot/resources/pride/gender_options.json') as f: + self.GENDER_OPTIONS = json.loads(await f.read()) + + @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(EASTER_COLOURS, key=lambda point: distance(point)) + r2, g2, b2 = closest_colours[0] + r = (r1 + r2) // 2 + g = (g1 + g2) // 2 + b = (b1 + b2) // 2 + + return (r, g, b) + + @staticmethod + def crop_avatar(avatar: 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, px: int) -> 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 + + def process_options(self, option: str, pixels: int) -> t.Tuple[str, int, str]: + """Does some shared preprocessing for the prideavatar commands.""" + return option.lower(), max(0, min(512, pixels)), self.GENDER_OPTIONS.get(option) + + def process_image( + self, + image_bytes: bytes, + pixels: int, + flag: str + ) -> discord.File: + """Constructs and returns the final image. Used by the pride commands.""" + # This line can raise UnidentifiedImageError and must be handled by the calling func. + avatar = Image.open(BytesIO(image_bytes)) + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + avatar = self.crop_avatar(avatar) + + ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) + ring = ring.convert("RGBA") + ring = self.crop_ring(ring, pixels) + + avatar.alpha_composite(ring, (0, 0)) + bufferedio = BytesIO() + avatar.save(bufferedio, format="PNG") + bufferedio.seek(0) + + return discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed + + async def send_image( + self, + 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.""" + try: + file = await in_thread(self.process_image, image_bytes, pixels, flag) + except UnidentifiedImageError: + ctx.send("Cannot identify image from provided URL") + return + + embed = discord.Embed( + name="Your Lovely Pride Avatar", + description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" + ) + embed.set_image(url="attachment://pride_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + await ctx.send(file=file, embed=embed) + + @commands.max_concurrency(1, commands.BucketType.guild, wait=True) + @commands.group() + async def pfp_modify(self, ctx: commands.Context) -> None: + """Groups all of the pfp modifing commands to allow a single concurrency limit.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @pfp_modify.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(): + image_bytes = await ctx.author.avatar_url.read() + avatar = Image.open(BytesIO(image_bytes)) + avatar = avatar.convert("RGBA").resize((1024, 1024)) + + # Pixilate and quantize + eightbit = avatar.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) + eightbit = eightbit.quantize() + + 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) + + @pfp_modify.command(pass_context=True, aliases=["easterify"]) + 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(): + + # Grabs image of avatar + image_bytes = await ctx.author.avatar_url_as(size=256).read() + + old = Image.open(BytesIO(image_bytes)) + old = old.convert("RGBA") + + # Grabs alpha channel since posterize can't be used with an RGBA image. + alpha = old.getchannel("A").getdata() + old = old.convert("RGB") + old = ImageOps.posterize(old, 6) + + data = old.getdata() + setted_data = set(data) + new_d = {} + + for x in setted_data: + new_d[x] = self.closest(x) + await asyncio.sleep(0) # Ensures discord doesn't break in the background. + new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + + im = Image.new("RGBA", old.size) + im.putdata(new_data) + + if colours: + send_message = ctx.send + ctx.send = send # Assigns ctx.send to a fake send + egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours) + if isinstance(egg, str): # When an error message occurs in eggdecorate. + return await send_message(egg) + + ratio = 64 / egg.height + egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) + egg = egg.convert("RGBA") + im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. + ctx.send = send_message # Reassigns ctx.send + else: + bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) + im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. + + bufferedio = BytesIO() + im.save(bufferedio, format="PNG") + + bufferedio.seek(0) + + file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed + embed = discord.Embed( + name="Your Lovely Easterified Avatar", + description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" + ) + embed.set_image(url="attachment://easterified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + @pfp_modify.group(aliases=["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, pixels, flag = self.process_options(option, pixels) + if flag is None: + return await ctx.send("I don't have that flag!") + + async with ctx.typing(): + image_bytes = await ctx.author.avatar_url.read() + await self.send_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, pixels, flag = self.process_options(option, pixels) + if flag is None: + return await ctx.send("I don't have that flag!") + + async with ctx.typing(): + async with aiohttp.ClientSession() as session: + try: + response = await session.get(url) + except aiohttp.client_exceptions.ClientConnectorError: + return await ctx.send("Cannot connect to provided URL!") + except aiohttp.client_exceptions.InvalidURL: + return await ctx.send("Invalid URL!") + if response.status != 200: + return await ctx.send("Bad response from provided URL!") + image_bytes = await response.read() + await self.send_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(self.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) + + @pfp_modify.command( + name='savatar', + aliases=('spookyavatar', 'spookify'), + brief='Spookify an user\'s avatar.' + ) + async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: + """A command to print the user's spookified avatar.""" + if user is None: + user = ctx.message.author + + async with ctx.typing(): + embed = discord.Embed(colour=0xFF0000) + embed.title = "Is this you or am I just really paranoid?" + embed.set_author(name=str(user.name), icon_url=user.avatar_url) + + image_bytes = await ctx.author.avatar_url.read() + im = Image.open(BytesIO(image_bytes)) + modified_im = spookifications.get_random_effect(im) + modified_im.save(str(ctx.message.id)+'.png') + f = discord.File(str(ctx.message.id)+'.png') + embed.set_image(url='attachment://'+str(ctx.message.id)+'.png') + + await ctx.send(file=f, embed=embed) + os.remove(str(ctx.message.id)+'.png') + + +def setup(bot: commands.Bot) -> None: + """Load the PfpModify cog.""" + bot.add_cog(PfpModify(bot)) diff --git a/bot/exts/halloween/spookyavatar.py b/bot/exts/halloween/spookyavatar.py deleted file mode 100644 index 2d7df678..00000000 --- a/bot/exts/halloween/spookyavatar.py +++ /dev/null @@ -1,52 +0,0 @@ -import logging -import os -from io import BytesIO - -import aiohttp -import discord -from PIL import Image -from discord.ext import commands - -from bot.utils.halloween import spookifications - -log = logging.getLogger(__name__) - - -class SpookyAvatar(commands.Cog): - """A cog that spookifies an avatar.""" - - def __init__(self, bot: commands.Bot): - self.bot = bot - - async def get(self, url: str) -> bytes: - """Returns the contents of the supplied URL.""" - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - return await resp.read() - - @commands.command(name='savatar', aliases=('spookyavatar', 'spookify'), - brief='Spookify an user\'s avatar.') - async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: - """A command to print the user's spookified avatar.""" - if user is None: - user = ctx.message.author - - async with ctx.typing(): - embed = discord.Embed(colour=0xFF0000) - embed.title = "Is this you or am I just really paranoid?" - embed.set_author(name=str(user.name), icon_url=user.avatar_url) - - image_bytes = await ctx.author.avatar_url.read() - im = Image.open(BytesIO(image_bytes)) - modified_im = spookifications.get_random_effect(im) - modified_im.save(str(ctx.message.id)+'.png') - f = discord.File(str(ctx.message.id)+'.png') - embed.set_image(url='attachment://'+str(ctx.message.id)+'.png') - - await ctx.send(file=f, embed=embed) - os.remove(str(ctx.message.id)+'.png') - - -def setup(bot: commands.Bot) -> None: - """Spooky avatar Cog load.""" - bot.add_cog(SpookyAvatar(bot)) diff --git a/bot/exts/pride/pride_avatar.py b/bot/exts/pride/pride_avatar.py deleted file mode 100644 index 2eade796..00000000 --- a/bot/exts/pride/pride_avatar.py +++ /dev/null @@ -1,177 +0,0 @@ -import logging -from io import BytesIO -from pathlib import Path -from typing import Tuple - -import aiohttp -import discord -from PIL import Image, ImageDraw, UnidentifiedImageError -from discord.ext.commands import Bot, Cog, Context, group - -from bot.constants import Colours - -log = logging.getLogger(__name__) - -OPTIONS = { - "agender": "agender", - "androgyne": "androgyne", - "androgynous": "androgyne", - "aromantic": "aromantic", - "aro": "aromantic", - "ace": "asexual", - "asexual": "asexual", - "bigender": "bigender", - "bisexual": "bisexual", - "bi": "bisexual", - "demiboy": "demiboy", - "demigirl": "demigirl", - "demi": "demisexual", - "demisexual": "demisexual", - "gay": "gay", - "lgbt": "gay", - "queer": "gay", - "homosexual": "gay", - "fluid": "genderfluid", - "genderfluid": "genderfluid", - "genderqueer": "genderqueer", - "intersex": "intersex", - "lesbian": "lesbian", - "non-binary": "nonbinary", - "enby": "nonbinary", - "nb": "nonbinary", - "nonbinary": "nonbinary", - "omnisexual": "omnisexual", - "omni": "omnisexual", - "pansexual": "pansexual", - "pan": "pansexual", - "pangender": "pangender", - "poly": "polysexual", - "polysexual": "polysexual", - "polyamory": "polyamory", - "polyamorous": "polyamory", - "transgender": "transgender", - "trans": "transgender", - "trigender": "trigender" -} - - -class PrideAvatar(Cog): - """Put an LGBT spin on your avatar!""" - - def __init__(self, bot: Bot): - self.bot = bot - - @staticmethod - def crop_avatar(avatar: Image) -> Image: - """This crops the avatar into a circle.""" - mask = Image.new("L", avatar.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0) + avatar.size, fill=255) - avatar.putalpha(mask) - return avatar - - @staticmethod - def crop_ring(ring: Image, px: int) -> Image: - """This crops the ring into a circle.""" - mask = Image.new("L", ring.size, 0) - draw = ImageDraw.Draw(mask) - draw.ellipse((0, 0) + ring.size, fill=255) - draw.ellipse((px, px, 1024-px, 1024-px), fill=0) - ring.putalpha(mask) - return ring - - @staticmethod - def process_options(option: str, pixels: int) -> Tuple[str, int, str]: - """Does some shared preprocessing for the prideavatar commands.""" - return option.lower(), max(0, min(512, pixels)), OPTIONS.get(option) - - async def process_image(self, ctx: Context, image_bytes: bytes, pixels: int, flag: str, option: str) -> None: - """Constructs the final image, embeds it, and sends it.""" - try: - avatar = Image.open(BytesIO(image_bytes)) - except UnidentifiedImageError: - return await ctx.send("Cannot identify image from provided URL") - avatar = avatar.convert("RGBA").resize((1024, 1024)) - - avatar = self.crop_avatar(avatar) - - ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) - ring = ring.convert("RGBA") - ring = self.crop_ring(ring, pixels) - - avatar.alpha_composite(ring, (0, 0)) - bufferedio = BytesIO() - avatar.save(bufferedio, format="PNG") - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed - embed = discord.Embed( - name="Your Lovely Pride Avatar", - description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" - ) - embed.set_image(url="attachment://pride_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - await ctx.send(file=file, embed=embed) - - @group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) - async def prideavatar(self, ctx: Context, option: str = "lgbt", pixels: int = 64) -> None: - """ - This surrounds an avatar with a border of a specified LGBT flag. - - This defaults to the LGBT rainbow flag if none is given. - The amount of pixels can be given which determines the thickness of the flag border. - This has a maximum of 512px and defaults to a 64px border. - The full image is 1024x1024. - """ - option, pixels, flag = self.process_options(option, pixels) - if flag is None: - return await ctx.send("I don't have that flag!") - - async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() - await self.process_image(ctx, image_bytes, pixels, flag, option) - - @prideavatar.command() - async def image(self, ctx: Context, url: str, option: str = "lgbt", pixels: int = 64) -> None: - """ - This surrounds the image specified by the URL with a border of a specified LGBT flag. - - This defaults to the LGBT rainbow flag if none is given. - The amount of pixels can be given which determines the thickness of the flag border. - This has a maximum of 512px and defaults to a 64px border. - The full image is 1024x1024. - """ - option, pixels, flag = self.process_options(option, pixels) - if flag is None: - return await ctx.send("I don't have that flag!") - - async with ctx.typing(): - async with aiohttp.ClientSession() as session: - try: - response = await session.get(url) - except aiohttp.client_exceptions.ClientConnectorError: - return await ctx.send("Cannot connect to provided URL!") - except aiohttp.client_exceptions.InvalidURL: - return await ctx.send("Invalid URL!") - if response.status != 200: - return await ctx.send("Bad response from provided URL!") - image_bytes = await response.read() - await self.process_image(ctx, image_bytes, pixels, flag, option) - - @prideavatar.command() - async def flags(self, ctx: Context) -> None: - """This lists the flags that can be used with the prideavatar command.""" - choices = sorted(set(OPTIONS.values())) - options = "• " + "\n• ".join(choices) - embed = discord.Embed( - title="I have the following flags:", - description=options, - colour=Colours.soft_red - ) - - await ctx.send(embed=embed) - - -def setup(bot: Bot) -> None: - """Cog load.""" - bot.add_cog(PrideAvatar(bot)) -- cgit v1.2.3 From e79a7730a3d8151622670f9ba38d05f2b267e2ec Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 18 Feb 2021 19:32:14 +0000 Subject: Log what func is being ran in the executor. --- bot/exts/evergreen/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/pfp_modify.py b/bot/exts/evergreen/pfp_modify.py index f356d2f6..4245432e 100644 --- a/bot/exts/evergreen/pfp_modify.py +++ b/bot/exts/evergreen/pfp_modify.py @@ -29,6 +29,7 @@ _EXECUTOR = ThreadPoolExecutor(10) async def in_thread(func: t.Callable, *args) -> asyncio.Future: """Allows non-async functions to work in async functions.""" + log.trace(f"Running {func.__name__} in an executor.") loop = asyncio.get_event_loop() return await loop.run_in_executor(_EXECUTOR, func, *args) @@ -138,7 +139,6 @@ class PfpModify(commands.Cog): await ctx.send(file=file, embed=embed) @commands.max_concurrency(1, commands.BucketType.guild, wait=True) - @commands.group() async def pfp_modify(self, ctx: commands.Context) -> None: """Groups all of the pfp modifing commands to allow a single concurrency limit.""" if not ctx.invoked_subcommand: -- cgit v1.2.3 From 032d4ae8300ed4570e0b471dd49f628f446cb1fa Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 18 Feb 2021 19:43:26 +0000 Subject: Add root alias support for commands --- bot/__init__.py | 9 +++++++++ bot/bot.py | 41 ++++++++++++++++++++++++++++++++++++++++ bot/command.py | 18 ++++++++++++++++++ bot/exts/evergreen/help.py | 4 +++- bot/exts/evergreen/pfp_modify.py | 3 ++- 5 files changed, 73 insertions(+), 2 deletions(-) create mode 100644 bot/command.py (limited to 'bot') diff --git a/bot/__init__.py b/bot/__init__.py index bdb18666..c8550537 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,10 +2,13 @@ import asyncio import logging import logging.handlers import os +from functools import partial, partialmethod from pathlib import Path import arrow +from discord.ext import commands +from bot.command import Command from bot.constants import Client @@ -70,3 +73,9 @@ logging.getLogger().info('Logging initialization complete') # On Windows, the selector event loop is required for aiodns. if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) diff --git a/bot/bot.py b/bot/bot.py index 97b09243..f51139bb 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -64,6 +64,26 @@ class Bot(commands.Bot): super().add_cog(cog) log.info(f"Cog loaded: {cog.qualified_name}") + def add_command(self, command: commands.Command) -> None: + """Add `command` as normal and then add its root aliases to the bot.""" + super().add_command(command) + self._add_root_aliases(command) + + def remove_command(self, name: str) -> Optional[commands.Command]: + """ + Remove a command/alias as normal and then remove its root aliases from the bot. + + Individual root aliases cannot be removed by this function. + To remove them, either remove the entire command or manually edit `bot.all_commands`. + """ + command = super().remove_command(name) + if command is None: + # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. + return + + self._remove_root_aliases(command) + return command + async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None: """Check command errors for UserInputError and reset the cooldown if thrown.""" if isinstance(exception, commands.UserInputError): @@ -124,6 +144,27 @@ class Bot(commands.Bot): """ await self._guild_available.wait() + def _add_root_aliases(self, command: commands.Command) -> None: + """Recursively add root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._add_root_aliases(subcommand) + + for alias in getattr(command, "root_aliases", ()): + if alias in self.all_commands: + raise commands.CommandRegistrationError(alias, alias_conflict=True) + + self.all_commands[alias] = command + + def _remove_root_aliases(self, command: commands.Command) -> None: + """Recursively remove root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._remove_root_aliases(subcommand) + + for alias in getattr(command, "root_aliases", ()): + self.all_commands.pop(alias, None) + _allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 00000000..0fb900f7 --- /dev/null +++ b/bot/command.py @@ -0,0 +1,18 @@ +from discord.ext import commands + + +class Command(commands.Command): + """ + A `discord.ext.commands.Command` subclass which supports root aliases. + + A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as + top-level commands rather than being aliases of the command's group. It's stored as an attribute + also named `root_aliases`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root_aliases = kwargs.get("root_aliases", []) + + if not isinstance(self.root_aliases, (list, tuple)): + raise TypeError("Root aliases of a command must be a list or a tuple of strings.") diff --git a/bot/exts/evergreen/help.py b/bot/exts/evergreen/help.py index 91147243..f557e42e 100644 --- a/bot/exts/evergreen/help.py +++ b/bot/exts/evergreen/help.py @@ -289,7 +289,9 @@ class HelpSession: 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) + 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') diff --git a/bot/exts/evergreen/pfp_modify.py b/bot/exts/evergreen/pfp_modify.py index 4245432e..a3f7e3f8 100644 --- a/bot/exts/evergreen/pfp_modify.py +++ b/bot/exts/evergreen/pfp_modify.py @@ -139,12 +139,13 @@ class PfpModify(commands.Cog): await ctx.send(file=file, embed=embed) @commands.max_concurrency(1, commands.BucketType.guild, wait=True) + @commands.group() async def pfp_modify(self, ctx: commands.Context) -> None: """Groups all of the pfp modifing commands to allow a single concurrency limit.""" if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) - @pfp_modify.command(name="8bitify") + @pfp_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(): -- cgit v1.2.3 From c7de5a2577b4f2975b3fab683f2d7459c5bda636 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 18 Feb 2021 22:04:27 +0000 Subject: Extend root aliases to support commands.Group --- bot/__init__.py | 6 +++++- bot/exts/evergreen/pfp_modify.py | 9 +++++++-- bot/group.py | 18 ++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) create mode 100644 bot/group.py (limited to 'bot') diff --git a/bot/__init__.py b/bot/__init__.py index c8550537..d0992912 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -10,6 +10,7 @@ from discord.ext import commands from bot.command import Command from bot.constants import Client +from bot.group import Group # Configure the "TRACE" logging level (e.g. "log.trace(message)") @@ -75,7 +76,10 @@ if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases. # Must be patched before any cogs are added. commands.command = partial(commands.command, cls=Command) commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) + +commands.group = partial(commands.group, cls=Group) +commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=Group) diff --git a/bot/exts/evergreen/pfp_modify.py b/bot/exts/evergreen/pfp_modify.py index a3f7e3f8..8a3eb77c 100644 --- a/bot/exts/evergreen/pfp_modify.py +++ b/bot/exts/evergreen/pfp_modify.py @@ -173,7 +173,7 @@ class PfpModify(commands.Cog): await ctx.send(file=file, embed=embed) - @pfp_modify.command(pass_context=True, aliases=["easterify"]) + @pfp_modify.command(pass_context=True, 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. @@ -249,7 +249,11 @@ class PfpModify(commands.Cog): await ctx.send(file=file, embed=embed) - @pfp_modify.group(aliases=["avatarpride", "pridepfp", "prideprofile"], invoke_without_command=True) + @pfp_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. @@ -310,6 +314,7 @@ class PfpModify(commands.Cog): @pfp_modify.command( name='savatar', aliases=('spookyavatar', 'spookify'), + root_aliases=('spookyavatar', 'spookify'), brief='Spookify an user\'s avatar.' ) async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: diff --git a/bot/group.py b/bot/group.py new file mode 100644 index 00000000..77092adf --- /dev/null +++ b/bot/group.py @@ -0,0 +1,18 @@ +from discord.ext import commands + + +class Group(commands.Group): + """ + A `discord.ext.commands.Group` subclass which supports root aliases. + + A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as + top-level commands rather than being aliases of the command's group. It's stored as an attribute + also named `root_aliases`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root_aliases = kwargs.get("root_aliases", []) + + if not isinstance(self.root_aliases, (list, tuple)): + raise TypeError("Root aliases of a command must be a list or a tuple of strings.") -- cgit v1.2.3 From a3a6550b80543c3ce7da28cf00b450b561e57ca9 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 19 Feb 2021 23:25:08 +0000 Subject: Share common code. --- bot/exts/evergreen/pfp_modify.py | 239 ++++++++++++++++++++------------------- 1 file changed, 122 insertions(+), 117 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/pfp_modify.py b/bot/exts/evergreen/pfp_modify.py index 8a3eb77c..5fb3b89d 100644 --- a/bot/exts/evergreen/pfp_modify.py +++ b/bot/exts/evergreen/pfp_modify.py @@ -1,16 +1,15 @@ import asyncio import json import logging -import os import typing as t from concurrent.futures import ThreadPoolExecutor from io import BytesIO from pathlib import Path import aiofiles -import aiohttp import discord -from PIL import Image, ImageDraw, ImageOps, UnidentifiedImageError +from PIL import Image, ImageDraw, ImageOps +from aiohttp import client_exceptions from discord.ext import commands from bot.constants import Colours @@ -69,7 +68,7 @@ class PfpModify(commands.Cog): return (r, g, b) @staticmethod - def crop_avatar(avatar: Image) -> Image: + def crop_avatar_circle(avatar: Image) -> Image: """This crops the avatar given into a circle.""" mask = Image.new("L", avatar.size, 0) draw = ImageDraw.Draw(mask) @@ -87,58 +86,71 @@ class PfpModify(commands.Cog): ring.putalpha(mask) return ring - def process_options(self, option: str, pixels: int) -> t.Tuple[str, int, str]: - """Does some shared preprocessing for the prideavatar commands.""" - return option.lower(), max(0, min(512, pixels)), self.GENDER_OPTIONS.get(option) + @staticmethod + def _apply_effect(image_bytes: bytes, effect: t.Callable, *args) -> discord.File: + im = Image.open(BytesIO(image_bytes)) + im = im.convert("RGBA") + im = effect(im, *args) - def process_image( - self, - image_bytes: bytes, + bufferedio = BytesIO() + im.save(bufferedio, format="PNG") + bufferedio.seek(0) + + return discord.File(bufferedio, filename="modified_avatar.png") + + @staticmethod + def pridify_effect( + image: Image, pixels: int, flag: str - ) -> discord.File: - """Constructs and returns the final image. Used by the pride commands.""" - # This line can raise UnidentifiedImageError and must be handled by the calling func. - avatar = Image.open(BytesIO(image_bytes)) - avatar = avatar.convert("RGBA").resize((1024, 1024)) - - avatar = self.crop_avatar(avatar) + ) -> Image: + """Applies the pride effect to the given image.""" + image = image.resize((1024, 1024)) + image = PfpModify.crop_avatar_circle(image) ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) ring = ring.convert("RGBA") - ring = self.crop_ring(ring, pixels) - - avatar.alpha_composite(ring, (0, 0)) - bufferedio = BytesIO() - avatar.save(bufferedio, format="PNG") - bufferedio.seek(0) + ring = PfpModify.crop_ring(ring, pixels) - return discord.File(bufferedio, filename="pride_avatar.png") # Creates file to be used in embed + image.alpha_composite(ring, (0, 0)) + return image - async def send_image( - self, - 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.""" - try: - file = await in_thread(self.process_image, image_bytes, pixels, flag) - except UnidentifiedImageError: - ctx.send("Cannot identify image from provided URL") - return + @staticmethod + def eight_bitify_effect(image: Image) -> Image: + """Applies the 8bit effect to the given image.""" + image = image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) + return image.quantize() - embed = discord.Embed( - name="Your Lovely Pride Avatar", - description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" - ) - embed.set_image(url="attachment://pride_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - await ctx.send(file=file, embed=embed) + @staticmethod + def easterify_effect(image: Image, overlay_image: Image = None) -> Image: + """Applies the easter effect to the given image.""" + 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 = 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() + setted_data = set(data) + new_d = {} + + for x in setted_data: + new_d[x] = PfpModify.closest(x) + new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + + im = Image.new("RGBA", image.size) + im.putdata(new_data) + im.alpha_composite(overlay_image, (im.width - overlay_image.width, (im.height - overlay_image.height)//2)) + return im - @commands.max_concurrency(1, commands.BucketType.guild, wait=True) @commands.group() async def pfp_modify(self, ctx: commands.Context) -> None: """Groups all of the pfp modifing commands to allow a single concurrency limit.""" @@ -150,25 +162,18 @@ class PfpModify(commands.Cog): """Pixelates your avatar and changes the palette to an 8bit one.""" async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() - avatar = Image.open(BytesIO(image_bytes)) - avatar = avatar.convert("RGBA").resize((1024, 1024)) - - # Pixilate and quantize - eightbit = avatar.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) - eightbit = eightbit.quantize() - - bufferedio = BytesIO() - eightbit.save(bufferedio, format="PNG") - bufferedio.seek(0) - - file = discord.File(bufferedio, filename="8bitavatar.png") + file = await in_thread( + self._apply_effect, + image_bytes, + self.eight_bitify_effect + ) 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_image(url="attachment://modified_avatar.png") embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) await ctx.send(file=file, embed=embed) @@ -194,61 +199,59 @@ class PfpModify(commands.Cog): return args[0] async with ctx.typing(): - - # Grabs image of avatar - image_bytes = await ctx.author.avatar_url_as(size=256).read() - - old = Image.open(BytesIO(image_bytes)) - old = old.convert("RGBA") - - # Grabs alpha channel since posterize can't be used with an RGBA image. - alpha = old.getchannel("A").getdata() - old = old.convert("RGB") - old = ImageOps.posterize(old, 6) - - data = old.getdata() - setted_data = set(data) - new_d = {} - - for x in setted_data: - new_d[x] = self.closest(x) - await asyncio.sleep(0) # Ensures discord doesn't break in the background. - new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] - - im = Image.new("RGBA", old.size) - im.putdata(new_data) - + 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. - return await send_message(egg) - - ratio = 64 / egg.height - egg = egg.resize((round(egg.width * ratio), round(egg.height * ratio))) - egg = egg.convert("RGBA") - im.alpha_composite(egg, (im.width - egg.width, (im.height - egg.height)//2)) # Right centre. + await send_message(egg) + return ctx.send = send_message # Reassigns ctx.send - else: - bunny = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) - im.alpha_composite(bunny, (im.width - bunny.width, (im.height - bunny.height)//2)) # Right centre. - bufferedio = BytesIO() - im.save(bufferedio, format="PNG") - - bufferedio.seek(0) + image_bytes = await ctx.author.avatar_url_as(size=256).read() + file = await in_thread( + self._apply_effect, + image_bytes, + self.easterify_effect, + egg + ) - file = discord.File(bufferedio, filename="easterified_avatar.png") # Creates file to be used in embed embed = discord.Embed( name="Your Lovely Easterified Avatar", description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" ) - embed.set_image(url="attachment://easterified_avatar.png") + embed.set_image(url="attachment://modified_avatar.png") embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) await ctx.send(file=file, embed=embed) + async def send_pride_image( + self, + 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 = await in_thread( + self._apply_effect, + image_bytes, + self.pridify_effect, + 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="attachment://modified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + await ctx.send(file=file, embed=embed) + @pfp_modify.group( aliases=["avatarpride", "pridepfp", "prideprofile"], root_aliases=("prideavatar", "avatarpride", "pridepfp", "prideprofile"), @@ -263,13 +266,15 @@ class PfpModify(commands.Cog): This has a maximum of 512px and defaults to a 64px border. The full image is 1024x1024. """ - option, pixels, flag = self.process_options(option, pixels) + option = option.lower() + pixels = max(0, min(512, pixels)) + flag = self.GENDER_OPTIONS.get(option) if flag is None: return await ctx.send("I don't have that flag!") async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() - await self.send_image(ctx, image_bytes, pixels, flag, option) + 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: @@ -281,22 +286,24 @@ class PfpModify(commands.Cog): This has a maximum of 512px and defaults to a 64px border. The full image is 1024x1024. """ - option, pixels, flag = self.process_options(option, pixels) + option = option.lower() + pixels = max(0, min(512, pixels)) + flag = self.GENDER_OPTIONS.get(option) if flag is None: return await ctx.send("I don't have that flag!") async with ctx.typing(): - async with aiohttp.ClientSession() as session: + async with self.bot.http_session as session: try: response = await session.get(url) - except aiohttp.client_exceptions.ClientConnectorError: + except client_exceptions.ClientConnectorError: return await ctx.send("Cannot connect to provided URL!") - except aiohttp.client_exceptions.InvalidURL: + except client_exceptions.InvalidURL: return await ctx.send("Invalid URL!") if response.status != 200: return await ctx.send("Bad response from provided URL!") image_bytes = await response.read() - await self.send_image(ctx, image_bytes, pixels, flag, option) + await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() async def flags(self, ctx: commands.Context) -> None: @@ -308,7 +315,6 @@ class PfpModify(commands.Cog): description=options, colour=Colours.soft_red ) - await ctx.send(embed=embed) @pfp_modify.command( @@ -323,19 +329,18 @@ class PfpModify(commands.Cog): user = ctx.message.author async with ctx.typing(): - embed = discord.Embed(colour=0xFF0000) - embed.title = "Is this you or am I just really paranoid?" + image_bytes = await ctx.author.avatar_url.read() + file = await in_thread(self._apply_effect, image_bytes, spookifications.get_random_effect) + + embed = discord.Embed( + title="Is this you or am I just really paranoid?", + colour=0xFF0000 + ) embed.set_author(name=str(user.name), icon_url=user.avatar_url) + embed.set_image(url='attachment://modified_avatar.png') + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - image_bytes = await ctx.author.avatar_url.read() - im = Image.open(BytesIO(image_bytes)) - modified_im = spookifications.get_random_effect(im) - modified_im.save(str(ctx.message.id)+'.png') - f = discord.File(str(ctx.message.id)+'.png') - embed.set_image(url='attachment://'+str(ctx.message.id)+'.png') - - await ctx.send(file=f, embed=embed) - os.remove(str(ctx.message.id)+'.png') + await ctx.send(file=file, embed=embed) def setup(bot: commands.Bot) -> None: -- cgit v1.2.3 From 326d50cdc039556e88e0b210a7124a4cc47a9275 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 20 Feb 2021 15:44:47 +0000 Subject: Split effects from cog --- bot/exts/evergreen/pfp_modify.py | 348 --------------------- .../evergreen/profile_pic_modification/__init__.py | 0 .../evergreen/profile_pic_modification/_effects.py | 122 ++++++++ .../profile_pic_modification/pfp_modify.py | 234 ++++++++++++++ 4 files changed, 356 insertions(+), 348 deletions(-) delete mode 100644 bot/exts/evergreen/pfp_modify.py create mode 100644 bot/exts/evergreen/profile_pic_modification/__init__.py create mode 100644 bot/exts/evergreen/profile_pic_modification/_effects.py create mode 100644 bot/exts/evergreen/profile_pic_modification/pfp_modify.py (limited to 'bot') diff --git a/bot/exts/evergreen/pfp_modify.py b/bot/exts/evergreen/pfp_modify.py deleted file mode 100644 index 5fb3b89d..00000000 --- a/bot/exts/evergreen/pfp_modify.py +++ /dev/null @@ -1,348 +0,0 @@ -import asyncio -import json -import logging -import typing as t -from concurrent.futures import ThreadPoolExecutor -from io import BytesIO -from pathlib import Path - -import aiofiles -import discord -from PIL import Image, ImageDraw, ImageOps -from aiohttp import client_exceptions -from discord.ext import commands - -from bot.constants import Colours -from bot.utils.halloween import spookifications - -log = logging.getLogger(__name__) - -EASTER_COLOURS = [ - (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), - (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), - (135, 206, 235), (0, 204, 204), (64, 224, 208) -] # Pastel colours - Easter-like - -_EXECUTOR = ThreadPoolExecutor(10) - - -async def in_thread(func: t.Callable, *args) -> asyncio.Future: - """Allows non-async functions to work in async functions.""" - log.trace(f"Running {func.__name__} in an executor.") - loop = asyncio.get_event_loop() - return await loop.run_in_executor(_EXECUTOR, func, *args) - - -class PfpModify(commands.Cog): - """Various commands for users to change their own profile picture.""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - self.bot.loop.create_task(self.init_cog()) - - async def init_cog(self) -> None: - """Initial load from resources asynchronously.""" - async with aiofiles.open('bot/resources/pride/gender_options.json') as f: - self.GENDER_OPTIONS = json.loads(await f.read()) - - @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(EASTER_COLOURS, key=lambda point: distance(point)) - r2, g2, b2 = closest_colours[0] - r = (r1 + r2) // 2 - g = (g1 + g2) // 2 - b = (b1 + b2) // 2 - - return (r, g, b) - - @staticmethod - def crop_avatar_circle(avatar: 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, px: int) -> 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 _apply_effect(image_bytes: bytes, effect: t.Callable, *args) -> discord.File: - 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="modified_avatar.png") - - @staticmethod - def pridify_effect( - image: Image, - pixels: int, - flag: str - ) -> Image: - """Applies the pride effect to the given image.""" - image = image.resize((1024, 1024)) - image = PfpModify.crop_avatar_circle(image) - - ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024)) - ring = ring.convert("RGBA") - ring = PfpModify.crop_ring(ring, pixels) - - image.alpha_composite(ring, (0, 0)) - return image - - @staticmethod - def eight_bitify_effect(image: Image) -> Image: - """Applies the 8bit effect to the given image.""" - image = image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) - return image.quantize() - - @staticmethod - def easterify_effect(image: Image, overlay_image: Image = None) -> Image: - """Applies the easter effect to the given image.""" - 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 = 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() - setted_data = set(data) - new_d = {} - - for x in setted_data: - new_d[x] = PfpModify.closest(x) - new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] - - im = Image.new("RGBA", image.size) - im.putdata(new_data) - im.alpha_composite(overlay_image, (im.width - overlay_image.width, (im.height - overlay_image.height)//2)) - return im - - @commands.group() - async def pfp_modify(self, ctx: commands.Context) -> None: - """Groups all of the pfp modifing commands to allow a single concurrency limit.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @pfp_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(): - image_bytes = await ctx.author.avatar_url.read() - file = await in_thread( - self._apply_effect, - image_bytes, - self.eight_bitify_effect - ) - - 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://modified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - @pfp_modify.command(pass_context=True, 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(): - 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 ctx.author.avatar_url_as(size=256).read() - file = await in_thread( - self._apply_effect, - image_bytes, - self.easterify_effect, - 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="attachment://modified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - async def send_pride_image( - self, - 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 = await in_thread( - self._apply_effect, - image_bytes, - self.pridify_effect, - 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="attachment://modified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - await ctx.send(file=file, embed=embed) - - @pfp_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 = self.GENDER_OPTIONS.get(option) - if flag is None: - return await ctx.send("I don't have that flag!") - - async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() - await self.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 = self.GENDER_OPTIONS.get(option) - if flag is None: - return await ctx.send("I don't have that flag!") - - async with ctx.typing(): - async with self.bot.http_session as session: - try: - response = await session.get(url) - except client_exceptions.ClientConnectorError: - return await ctx.send("Cannot connect to provided URL!") - except client_exceptions.InvalidURL: - return await ctx.send("Invalid URL!") - if response.status != 200: - return await ctx.send("Bad response from provided URL!") - image_bytes = await response.read() - await self.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(self.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) - - @pfp_modify.command( - name='savatar', - aliases=('spookyavatar', 'spookify'), - root_aliases=('spookyavatar', 'spookify'), - brief='Spookify an user\'s avatar.' - ) - async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: - """A command to print the user's spookified avatar.""" - if user is None: - user = ctx.message.author - - async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() - file = await in_thread(self._apply_effect, image_bytes, spookifications.get_random_effect) - - embed = discord.Embed( - title="Is this you or am I just really paranoid?", - colour=0xFF0000 - ) - embed.set_author(name=str(user.name), icon_url=user.avatar_url) - embed.set_image(url='attachment://modified_avatar.png') - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) - - await ctx.send(file=file, embed=embed) - - -def setup(bot: commands.Bot) -> None: - """Load the PfpModify cog.""" - bot.add_cog(PfpModify(bot)) diff --git a/bot/exts/evergreen/profile_pic_modification/__init__.py b/bot/exts/evergreen/profile_pic_modification/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py new file mode 100644 index 00000000..fbe5f706 --- /dev/null +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -0,0 +1,122 @@ +import typing as t +from io import BytesIO +from pathlib import Path + +import discord +from PIL import Image, ImageDraw, ImageOps + +EASTER_COLOURS = [ + (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), + (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), + (135, 206, 235), (0, 204, 204), (64, 224, 208) +] # Pastel colours - Easter-like + + +class PfpEffects(): + """Implements various image effects.""" + + @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(EASTER_COLOURS, key=lambda point: distance(point)) + r2, g2, b2 = closest_colours[0] + r = (r1 + r2) // 2 + g = (g1 + g2) // 2 + b = (b1 + b2) // 2 + + return (r, g, b) + + @staticmethod + def crop_avatar_circle(avatar: 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, px: int) -> 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 _apply_effect(image_bytes: bytes, effect: t.Callable, *args) -> discord.File: + 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="modified_avatar.png") + + @staticmethod + def pridify_effect( + image: Image, + pixels: int, + flag: str + ) -> Image: + """Applies the 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: + """Applies the 8bit effect to the given image.""" + image = image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) + return image.quantize() + + @staticmethod + def easterify_effect(image: Image, overlay_image: Image = None) -> Image: + """Applies the easter effect to the given image.""" + 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 = 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() + setted_data = set(data) + new_d = {} + + for x in setted_data: + new_d[x] = PfpEffects.closest(x) + new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + + im = Image.new("RGBA", image.size) + im.putdata(new_data) + im.alpha_composite(overlay_image, (im.width - overlay_image.width, (im.height - overlay_image.height)//2)) + return im diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py new file mode 100644 index 00000000..51742257 --- /dev/null +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -0,0 +1,234 @@ +import asyncio +import json +import logging +import typing as t +from concurrent.futures import ThreadPoolExecutor + +import aiofiles +import discord +from aiohttp import client_exceptions +from discord.ext import commands + +from bot.constants import Colours +from bot.exts.evergreen.profile_pic_modification._effects import PfpEffects +from bot.utils.halloween import spookifications + +log = logging.getLogger(__name__) + +_EXECUTOR = ThreadPoolExecutor(10) + + +async def in_thread(func: t.Callable, *args) -> asyncio.Future: + """Allows non-async functions to work in async functions.""" + log.trace(f"Running {func.__name__} in an executor.") + loop = asyncio.get_event_loop() + return await loop.run_in_executor(_EXECUTOR, func, *args) + + +class PfpModify(commands.Cog): + """Various commands for users to change their own profile picture.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self.bot.loop.create_task(self.init_cog()) + + async def init_cog(self) -> None: + """Initial load from resources asynchronously.""" + async with aiofiles.open('bot/resources/pride/gender_options.json') as f: + self.GENDER_OPTIONS = json.loads(await f.read()) + + @commands.group() + async def pfp_modify(self, ctx: commands.Context) -> None: + """Groups all of the pfp modifing commands to allow a single concurrency limit.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @pfp_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(): + image_bytes = await ctx.author.avatar_url.read() + file = await in_thread( + PfpEffects._apply_effect, + image_bytes, + PfpEffects.eight_bitify_effect + ) + + 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://modified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + @pfp_modify.command(pass_context=True, 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(): + 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 ctx.author.avatar_url_as(size=256).read() + file = await in_thread( + PfpEffects._apply_effect, + image_bytes, + PfpEffects.easterify_effect, + 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="attachment://modified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + async def send_pride_image( + self, + 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 = await in_thread( + PfpEffects._apply_effect, + image_bytes, + PfpEffects.pridify_effect, + 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="attachment://modified_avatar.png") + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + await ctx.send(file=file, embed=embed) + + @pfp_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 = self.GENDER_OPTIONS.get(option) + if flag is None: + return await ctx.send("I don't have that flag!") + + async with ctx.typing(): + image_bytes = await ctx.author.avatar_url.read() + await self.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 = self.GENDER_OPTIONS.get(option) + if flag is None: + return await ctx.send("I don't have that flag!") + + async with ctx.typing(): + async with self.bot.http_session as session: + try: + response = await session.get(url) + except client_exceptions.ClientConnectorError: + return await ctx.send("Cannot connect to provided URL!") + except client_exceptions.InvalidURL: + return await ctx.send("Invalid URL!") + if response.status != 200: + return await ctx.send("Bad response from provided URL!") + image_bytes = await response.read() + await self.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(self.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) + + @pfp_modify.command( + name='spookyavatar', + aliases=('savatar', 'spookify'), + root_aliases=('spookyavatar', 'spookify', 'savatar'), + brief='Spookify an user\'s avatar.' + ) + async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: + """A command to print the user's spookified avatar.""" + if user is None: + user = ctx.message.author + + async with ctx.typing(): + image_bytes = await ctx.author.avatar_url.read() + file = await in_thread(PfpEffects._apply_effect, image_bytes, spookifications.get_random_effect) + + embed = discord.Embed( + title="Is this you or am I just really paranoid?", + colour=0xFF0000 + ) + embed.set_author(name=str(user.name), icon_url=user.avatar_url) + embed.set_image(url='attachment://modified_avatar.png') + embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + + await ctx.send(file=file, embed=embed) + + +def setup(bot: commands.Bot) -> None: + """Load the PfpModify cog.""" + bot.add_cog(PfpModify(bot)) -- cgit v1.2.3 From e210354fc5d8e0970e1ea2e75bb28fa314fa72fa Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 20 Feb 2021 15:50:03 +0000 Subject: Hoist apply effect and remove private _. --- .../evergreen/profile_pic_modification/_effects.py | 25 +++++++++++----------- .../profile_pic_modification/pfp_modify.py | 8 +++---- 2 files changed, 17 insertions(+), 16 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index fbe5f706..a290ed3d 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -15,6 +15,19 @@ EASTER_COLOURS = [ class PfpEffects(): """Implements various image effects.""" + @staticmethod + def apply_effect(image_bytes: bytes, effect: t.Callable, *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="modified_avatar.png") + @staticmethod def closest(x: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]: """ @@ -56,18 +69,6 @@ class PfpEffects(): ring.putalpha(mask) return ring - @staticmethod - def _apply_effect(image_bytes: bytes, effect: t.Callable, *args) -> discord.File: - 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="modified_avatar.png") - @staticmethod def pridify_effect( image: Image, diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 51742257..a58f44d2 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -49,7 +49,7 @@ class PfpModify(commands.Cog): async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() file = await in_thread( - PfpEffects._apply_effect, + PfpEffects.apply_effect, image_bytes, PfpEffects.eight_bitify_effect ) @@ -97,7 +97,7 @@ class PfpModify(commands.Cog): image_bytes = await ctx.author.avatar_url_as(size=256).read() file = await in_thread( - PfpEffects._apply_effect, + PfpEffects.apply_effect, image_bytes, PfpEffects.easterify_effect, egg @@ -123,7 +123,7 @@ class PfpModify(commands.Cog): """Gets and sends the image in an embed. Used by the pride commands.""" async with ctx.typing(): file = await in_thread( - PfpEffects._apply_effect, + PfpEffects.apply_effect, image_bytes, PfpEffects.pridify_effect, pixels, @@ -216,7 +216,7 @@ class PfpModify(commands.Cog): async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() - file = await in_thread(PfpEffects._apply_effect, image_bytes, spookifications.get_random_effect) + file = await in_thread(PfpEffects.apply_effect, image_bytes, spookifications.get_random_effect) embed = discord.Embed( title="Is this you or am I just really paranoid?", -- cgit v1.2.3 From a30f4448bfe411cf418e4de938870d89680d68df Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 20 Feb 2021 15:53:42 +0000 Subject: Don't return discord.Message from ctx.send calls --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index a58f44d2..f3e8d426 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -156,7 +156,8 @@ class PfpModify(commands.Cog): pixels = max(0, min(512, pixels)) flag = self.GENDER_OPTIONS.get(option) if flag is None: - return await ctx.send("I don't have that flag!") + await ctx.send("I don't have that flag!") + return async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() @@ -176,7 +177,8 @@ class PfpModify(commands.Cog): pixels = max(0, min(512, pixels)) flag = self.GENDER_OPTIONS.get(option) if flag is None: - return await ctx.send("I don't have that flag!") + await ctx.send("I don't have that flag!") + return async with ctx.typing(): async with self.bot.http_session as session: -- cgit v1.2.3 From 6e7521c74e9c34159d8ba4873f09f5277f47908e Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 22 Feb 2021 18:35:10 +0000 Subject: Improve doc string. --- bot/exts/evergreen/profile_pic_modification/_effects.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index a290ed3d..1179100c 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -13,7 +13,11 @@ EASTER_COLOURS = [ class PfpEffects(): - """Implements various image effects.""" + """ + Implements various image effects. + + All of these methods are blocking, so should be ran in threads. + """ @staticmethod def apply_effect(image_bytes: bytes, effect: t.Callable, *args) -> discord.File: -- cgit v1.2.3 From 64576f56a69ec26406d7eaa11956d93db642fc3a Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 5 Mar 2021 22:05:56 +0000 Subject: Rename in_thread fuction for clarity. --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index f3e8d426..df5005c9 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) _EXECUTOR = ThreadPoolExecutor(10) -async def in_thread(func: t.Callable, *args) -> asyncio.Future: +async def in_executor(func: t.Callable, *args) -> asyncio.Future: """Allows non-async functions to work in async functions.""" log.trace(f"Running {func.__name__} in an executor.") loop = asyncio.get_event_loop() @@ -48,7 +48,7 @@ class PfpModify(commands.Cog): """Pixelates your avatar and changes the palette to an 8bit one.""" async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() - file = await in_thread( + file = await in_executor( PfpEffects.apply_effect, image_bytes, PfpEffects.eight_bitify_effect @@ -96,7 +96,7 @@ class PfpModify(commands.Cog): ctx.send = send_message # Reassigns ctx.send image_bytes = await ctx.author.avatar_url_as(size=256).read() - file = await in_thread( + file = await in_executor( PfpEffects.apply_effect, image_bytes, PfpEffects.easterify_effect, @@ -122,7 +122,7 @@ class PfpModify(commands.Cog): ) -> None: """Gets and sends the image in an embed. Used by the pride commands.""" async with ctx.typing(): - file = await in_thread( + file = await in_executor( PfpEffects.apply_effect, image_bytes, PfpEffects.pridify_effect, @@ -218,7 +218,7 @@ class PfpModify(commands.Cog): async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() - file = await in_thread(PfpEffects.apply_effect, image_bytes, spookifications.get_random_effect) + file = await in_executor(PfpEffects.apply_effect, image_bytes, spookifications.get_random_effect) embed = discord.Embed( title="Is this you or am I just really paranoid?", -- cgit v1.2.3 From 013ce6da618ee9fdb7ec99d0c3a538f0aa8c745d Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 6 Mar 2021 12:12:22 +0000 Subject: Improve readibility of code, and fix grammar issues. --- .../evergreen/profile_pic_modification/_effects.py | 24 +++++++++++++--------- .../profile_pic_modification/pfp_modify.py | 18 ++++++++-------- 2 files changed, 23 insertions(+), 19 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index 1179100c..99010931 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -12,7 +12,7 @@ EASTER_COLOURS = [ ] # Pastel colours - Easter-like -class PfpEffects(): +class PfpEffects: """ Implements various image effects. @@ -37,14 +37,14 @@ class PfpEffects(): """ Finds the closest easter colour to a given pixel. - Returns a merge between the original colour and the closest colour + 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) + return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 closest_colours = sorted(EASTER_COLOURS, key=lambda point: distance(point)) r2, g2, b2 = closest_colours[0] @@ -52,7 +52,7 @@ class PfpEffects(): g = (g1 + g2) // 2 b = (b1 + b2) // 2 - return (r, g, b) + return r, g, b @staticmethod def crop_avatar_circle(avatar: Image) -> Image: @@ -114,14 +114,18 @@ class PfpEffects(): image = ImageOps.posterize(image, 6) data = image.getdata() - setted_data = set(data) - new_d = {} + data_set = set(data) + easterified_data_set = {} - for x in setted_data: - new_d[x] = PfpEffects.closest(x) - new_data = [(*new_d[x], alpha[i]) if x in new_d else x for i, x in enumerate(data)] + 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_data) + im.putdata(new_pixel_data) im.alpha_composite(overlay_image, (im.width - overlay_image.width, (im.height - overlay_image.height)//2)) return im diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index df5005c9..1d5b7208 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -39,7 +39,7 @@ class PfpModify(commands.Cog): @commands.group() async def pfp_modify(self, ctx: commands.Context) -> None: - """Groups all of the pfp modifing commands to allow a single concurrency limit.""" + """Groups all of the pfp modifying commands to allow a single concurrency limit.""" if not ctx.invoked_subcommand: await ctx.send_help(ctx.command) @@ -56,11 +56,11 @@ class PfpModify(commands.Cog): embed = discord.Embed( title="Your 8-bit avatar", - description='Here is your avatar. I think it looks all cool and "retro"' + description="Here is your avatar. I think it looks all cool and 'retro'." ) embed.set_image(url="attachment://modified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) await ctx.send(file=file, embed=embed) @@ -104,11 +104,11 @@ class PfpModify(commands.Cog): ) embed = discord.Embed( - name="Your Lovely Easterified Avatar", + name="Your Lovely Easterified Avatar!", description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" ) embed.set_image(url="attachment://modified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) await ctx.send(file=file, embed=embed) @@ -131,11 +131,11 @@ class PfpModify(commands.Cog): ) embed = discord.Embed( - name="Your Lovely Pride Avatar", + name="Your Lovely Pride Avatar!", description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" ) embed.set_image(url="attachment://modified_avatar.png") - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) await ctx.send(file=file, embed=embed) @pfp_modify.group( @@ -222,11 +222,11 @@ class PfpModify(commands.Cog): embed = discord.Embed( title="Is this you or am I just really paranoid?", - colour=0xFF0000 + colour=Colours.soft_red ) embed.set_author(name=str(user.name), icon_url=user.avatar_url) embed.set_image(url='attachment://modified_avatar.png') - embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url) + embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) await ctx.send(file=file, embed=embed) -- cgit v1.2.3 From 67db0d13d35cda2fcaa7d3bb20e22b38bb264487 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 6 Mar 2021 12:13:22 +0000 Subject: Convert method to static as it doesn't use class attributes. --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 1d5b7208..45a41e67 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -112,8 +112,8 @@ class PfpModify(commands.Cog): await ctx.send(file=file, embed=embed) + @staticmethod async def send_pride_image( - self, ctx: commands.Context, image_bytes: bytes, pixels: int, -- cgit v1.2.3 From 50a344d5caa006df1cbb033da4101b6cfa71529c Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 6 Mar 2021 12:14:23 +0000 Subject: Fix return type of in_executor. Its return type varies, based on the Callable given. --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 45a41e67..bba688aa 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -18,7 +18,7 @@ log = logging.getLogger(__name__) _EXECUTOR = ThreadPoolExecutor(10) -async def in_executor(func: t.Callable, *args) -> asyncio.Future: +async def in_executor(func: t.Callable, *args) -> t.Any: """Allows non-async functions to work in async functions.""" log.trace(f"Running {func.__name__} in an executor.") loop = asyncio.get_event_loop() -- cgit v1.2.3 From ea44e8ed8f5804cf5d80ab2318db6359c1e22c17 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 6 Mar 2021 12:15:28 +0000 Subject: Don't return discord.Messages. --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index bba688aa..7d4326a4 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -185,11 +185,16 @@ class PfpModify(commands.Cog): try: response = await session.get(url) except client_exceptions.ClientConnectorError: - return await ctx.send("Cannot connect to provided URL!") + await ctx.send("Cannot connect to provided URL!") + return except client_exceptions.InvalidURL: - return await ctx.send("Invalid URL!") + await ctx.send("Invalid URL!") + return + if response.status != 200: - return await ctx.send("Bad response from provided URL!") + await ctx.send("Bad response from provided URL!") + return + image_bytes = await response.read() await self.send_pride_image(ctx, image_bytes, pixels, flag, option) -- cgit v1.2.3 From 49ce88636124b826437c17a3c39c60c0840c1256 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 6 Mar 2021 12:16:00 +0000 Subject: Remove superfluous str cast. --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 7d4326a4..11222563 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -229,7 +229,7 @@ class PfpModify(commands.Cog): title="Is this you or am I just really paranoid?", colour=Colours.soft_red ) - embed.set_author(name=str(user.name), icon_url=user.avatar_url) + embed.set_author(name=user.name, icon_url=user.avatar_url) embed.set_image(url='attachment://modified_avatar.png') embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url) -- cgit v1.2.3 From 365a272ce235706006a36acd0eac09637205a1ae Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 6 Mar 2021 12:20:25 +0000 Subject: Split up complex line. --- bot/exts/evergreen/profile_pic_modification/_effects.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index 99010931..dda58006 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -127,5 +127,8 @@ class PfpEffects: 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)) + im.alpha_composite( + overlay_image, + (im.width - overlay_image.width, (im.height - overlay_image.height) // 2) + ) return im -- cgit v1.2.3 From 2ff509601fb97466ba6d980d226f6c0e481a1961 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 10 Mar 2021 19:31:37 +0000 Subject: Move colours to constants --- bot/constants.py | 16 ++++++++++++++++ bot/exts/evergreen/profile_pic_modification/_effects.py | 8 ++------ 2 files changed, 18 insertions(+), 6 deletions(-) (limited to 'bot') diff --git a/bot/constants.py b/bot/constants.py index b8e30a7c..5c95d9c1 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -162,6 +162,22 @@ class Colours: python_yellow = 0xFFD43B grass_green = 0x66ff00 + easter_like_colours = [ + (255, 247, 0), + (255, 255, 224), + (0, 255, 127), + (189, 252, 201), + (255, 192, 203), + (255, 160, 122), + (181, 115, 220), + (221, 160, 221), + (200, 162, 200), + (238, 130, 238), + (135, 206, 235), + (0, 204, 204), + (64, 224, 208), + ] + class Emojis: star = "\u2B50" diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index dda58006..b0d50f4b 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -5,11 +5,7 @@ from pathlib import Path import discord from PIL import Image, ImageDraw, ImageOps -EASTER_COLOURS = [ - (255, 247, 0), (255, 255, 224), (0, 255, 127), (189, 252, 201), (255, 192, 203), - (255, 160, 122), (181, 115, 220), (221, 160, 221), (200, 162, 200), (238, 130, 238), - (135, 206, 235), (0, 204, 204), (64, 224, 208) -] # Pastel colours - Easter-like +from bot.constants import Colours class PfpEffects: @@ -46,7 +42,7 @@ class PfpEffects: r2, g2, b2 = point return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 - closest_colours = sorted(EASTER_COLOURS, key=lambda point: distance(point)) + closest_colours = sorted(Colours.easter_like_colours, key=lambda point: distance(point)) r2, g2, b2 = closest_colours[0] r = (r1 + r2) // 2 g = (g1 + g2) // 2 -- cgit v1.2.3 From 3d54a88ee1d7fd5d9ece91ecea998e8da107ff22 Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 10 Mar 2021 19:33:30 +0000 Subject: Improve docstring and split line for readibility --- bot/exts/evergreen/profile_pic_modification/_effects.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index b0d50f4b..3f4e796d 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -88,8 +88,14 @@ class PfpEffects: @staticmethod def eight_bitify_effect(image: Image) -> Image: - """Applies the 8bit effect to the given image.""" - image = image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST) + """ + 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 -- cgit v1.2.3 From 33141d9fa9d3ffe2120fc4cf2fcba824d047fe7e Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 10 Mar 2021 19:34:00 +0000 Subject: Remove double assignment of a variable --- bot/exts/evergreen/profile_pic_modification/_effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index 3f4e796d..ef0a3f37 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -109,7 +109,7 @@ class PfpEffects: )) overlay_image = overlay_image.convert("RGBA") else: - overlay_image = overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) + overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png")) alpha = image.getchannel("A").getdata() image = image.convert("RGB") -- cgit v1.2.3 From d90f735913b087104a24aa718b6473fa0b39384c Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 11 Mar 2021 17:55:20 +0000 Subject: Use a more specific filename for avatar modifiying commands --- .../evergreen/profile_pic_modification/_effects.py | 4 +- .../profile_pic_modification/pfp_modify.py | 44 ++++++++++++++++++---- 2 files changed, 39 insertions(+), 9 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index ef0a3f37..ae606dcb 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -16,7 +16,7 @@ class PfpEffects: """ @staticmethod - def apply_effect(image_bytes: bytes, effect: t.Callable, *args) -> discord.File: + 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") @@ -26,7 +26,7 @@ class PfpEffects: im.save(bufferedio, format="PNG") bufferedio.seek(0) - return discord.File(bufferedio, filename="modified_avatar.png") + return discord.File(bufferedio, filename=filename) @staticmethod def closest(x: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]: diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 11222563..299ed107 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -17,6 +17,8 @@ log = logging.getLogger(__name__) _EXECUTOR = ThreadPoolExecutor(10) +FILENAME_STRING = "{effect}_{author}.png" + async def in_executor(func: t.Callable, *args) -> t.Any: """Allows non-async functions to work in async functions.""" @@ -48,10 +50,16 @@ class PfpModify(commands.Cog): """Pixelates your avatar and changes the palette to an 8bit one.""" async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() + file_name = FILENAME_STRING.format( + effect="eightbit_avatar", + author=ctx.author.display_name + ) + file = await in_executor( PfpEffects.apply_effect, image_bytes, - PfpEffects.eight_bitify_effect + PfpEffects.eight_bitify_effect, + file_name ) embed = discord.Embed( @@ -59,10 +67,10 @@ class PfpModify(commands.Cog): description="Here is your avatar. I think it looks all cool and 'retro'." ) - embed.set_image(url="attachment://modified_avatar.png") + 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) + await ctx.send(embed=embed, file=file) @pfp_modify.command(pass_context=True, aliases=["easterify"], root_aliases=("easterify", "avatareasterify")) async def avatareasterify(self, ctx: commands.Context, *colours: t.Union[discord.Colour, str]) -> None: @@ -96,10 +104,16 @@ class PfpModify(commands.Cog): ctx.send = send_message # Reassigns ctx.send image_bytes = await ctx.author.avatar_url_as(size=256).read() + file_name = FILENAME_STRING.format( + effect="easterified_avatar", + author=ctx.author.display_name + ) + file = await in_executor( PfpEffects.apply_effect, image_bytes, PfpEffects.easterify_effect, + file_name, egg ) @@ -107,7 +121,7 @@ class PfpModify(commands.Cog): name="Your Lovely Easterified Avatar!", description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D" ) - embed.set_image(url="attachment://modified_avatar.png") + 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) @@ -122,10 +136,16 @@ class PfpModify(commands.Cog): ) -> None: """Gets and sends the image in an embed. Used by the pride commands.""" async with ctx.typing(): + file_name = FILENAME_STRING.format( + effect="pride_avatar", + author=ctx.author.display_name + ) + file = await in_executor( PfpEffects.apply_effect, image_bytes, PfpEffects.pridify_effect, + file_name, pixels, flag ) @@ -134,7 +154,7 @@ class PfpModify(commands.Cog): name="Your Lovely Pride Avatar!", description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D" ) - embed.set_image(url="attachment://modified_avatar.png") + 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) @@ -223,14 +243,24 @@ class PfpModify(commands.Cog): async with ctx.typing(): image_bytes = await ctx.author.avatar_url.read() - file = await in_executor(PfpEffects.apply_effect, image_bytes, spookifications.get_random_effect) + + file_name = FILENAME_STRING.format( + effect="pride_avatar", + author=ctx.author.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=user.name, icon_url=user.avatar_url) - embed.set_image(url='attachment://modified_avatar.png') + 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) -- cgit v1.2.3 From 856cffbbfa2411fa246a2ff988b1199e0dcb4030 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 11 Mar 2021 17:59:39 +0000 Subject: Defer errors in pride image to error handler --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 299ed107..68750fe2 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -8,6 +8,7 @@ import aiofiles import discord from aiohttp import client_exceptions from discord.ext import commands +from discord.ext.commands.errors import BadArgument from bot.constants import Colours from bot.exts.evergreen.profile_pic_modification._effects import PfpEffects @@ -205,11 +206,9 @@ class PfpModify(commands.Cog): try: response = await session.get(url) except client_exceptions.ClientConnectorError: - await ctx.send("Cannot connect to provided URL!") - return + raise BadArgument("Cannot connect to provided URL!") except client_exceptions.InvalidURL: - await ctx.send("Invalid URL!") - return + raise BadArgument("Invalid URL!") if response.status != 200: await ctx.send("Bad response from provided URL!") -- cgit v1.2.3 From 6c1b715bae96980309af3b38a5ff6cad6ce49f95 Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Thu, 11 Mar 2021 18:01:22 +0000 Subject: Update docstring to refer to groups rather than commands Co-authored-by: Shivansh-007 <69356296+Shivansh-007@users.noreply.github.com> --- bot/group.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/group.py b/bot/group.py index 77092adf..a7bc59b7 100644 --- a/bot/group.py +++ b/bot/group.py @@ -6,7 +6,7 @@ class Group(commands.Group): A `discord.ext.commands.Group` subclass which supports root aliases. A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as - top-level commands rather than being aliases of the command's group. It's stored as an attribute + top-level groups rather than being aliases of the command's group. It's stored as an attribute also named `root_aliases`. """ @@ -15,4 +15,4 @@ class Group(commands.Group): self.root_aliases = kwargs.get("root_aliases", []) if not isinstance(self.root_aliases, (list, tuple)): - raise TypeError("Root aliases of a command must be a list or a tuple of strings.") + raise TypeError("Root aliases of a group must be a list or a tuple of strings.") -- cgit v1.2.3 From 335702f65429c6f1ebc9a9a50ffc528bcfd38581 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 11 Mar 2021 18:06:22 +0000 Subject: Fix filename for spooky effect avatars --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 68750fe2..9b874900 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -244,7 +244,7 @@ class PfpModify(commands.Cog): image_bytes = await ctx.author.avatar_url.read() file_name = FILENAME_STRING.format( - effect="pride_avatar", + effect="spooky_avatar", author=ctx.author.display_name ) file = await in_executor( -- cgit v1.2.3 From 88e7ab58ff6ad74a8323c828cc5411886bb57dbe Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Sat, 13 Mar 2021 14:57:54 +0000 Subject: Use user reference passed via command The command takes in user as argument but doesn't use it while converting the image into bytes. Co-authored-by: Shivansh-007 <69356296+Shivansh-007@users.noreply.github.com> --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 9b874900..aa0f0404 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -241,11 +241,11 @@ class PfpModify(commands.Cog): user = ctx.message.author async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() + image_bytes = await user.avatar_url.read() file_name = FILENAME_STRING.format( effect="spooky_avatar", - author=ctx.author.display_name + author=user.display_name ) file = await in_executor( PfpEffects.apply_effect, -- cgit v1.2.3 From d8b30f2a1a99f1c301c4d1e675dc708b90d0e652 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 13 Mar 2021 15:54:15 +0000 Subject: Use new help command ext Since sir-lancebot#625 was merged, we now have bot.utils.extensions.invoke_help_command which sends a nicely formatted help embed. --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index aa0f0404..b13ba574 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -12,6 +12,7 @@ from discord.ext.commands.errors import BadArgument from bot.constants import Colours from bot.exts.evergreen.profile_pic_modification._effects import PfpEffects +from bot.utils.extensions import invoke_help_command from bot.utils.halloween import spookifications log = logging.getLogger(__name__) @@ -44,7 +45,7 @@ class PfpModify(commands.Cog): async def pfp_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 ctx.send_help(ctx.command) + await invoke_help_command(ctx) @pfp_modify.command(name="8bitify", root_aliases=("8bitify",)) async def eightbit_command(self, ctx: commands.Context) -> None: -- cgit v1.2.3 From 8c09ba1db566a6c1051479e353ad025392e7e5bb Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 14 Mar 2021 13:34:27 +0000 Subject: Remove aiofiles dep --- Pipfile | 1 - Pipfile.lock | 28 ++++++++-------------- .../profile_pic_modification/pfp_modify.py | 16 +++++-------- 3 files changed, 16 insertions(+), 29 deletions(-) (limited to 'bot') diff --git a/Pipfile b/Pipfile index 79a5dfc8..e7e01a31 100644 --- a/Pipfile +++ b/Pipfile @@ -15,7 +15,6 @@ PyYAML = "~=5.3.1" "discord.py" = {extras = ["voice"], version = "~=1.5.1"} async-rediscache = {extras = ["fakeredis"], version = "~=0.1.4"} emojis = "~=0.6.0" -aiofiles = "~=0.6" [dev-packages] flake8 = "~=3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 9d0a988e..19bcb719 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "91281e9ed353fea748de3da19abd7bef402402b23fc78a1260dc8bf8bd2bd98c" + "sha256": "b4aaaacbab13179145e36d7b86c736db512286f6cce8e513cc30c48d68fe3810" }, "pipfile-spec": 6, "requires": { @@ -24,14 +24,6 @@ "index": "pypi", "version": "==2.0.0" }, - "aiofiles": { - "hashes": [ - "sha256:bd3019af67f83b739f8e4053c6c0512a7f545b9a8d91aaeab55e6e0f9d123c27", - "sha256:e0281b157d3d5d59d803e3f4557dcc9a3dff28a4dd4829a9ff478adae50ca092" - ], - "index": "pypi", - "version": "==0.6.0" - }, "aiohttp": { "hashes": [ "sha256:1a4160579ffbc1b69e88cb6ca8bb0fbd4947dfcbf9fb1e2a4fc4c7a4a986c1fe", @@ -531,11 +523,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:3a377140556aecf11fa9f3bb18c10db01f5ea56dc79a730e2ec9b4f1f49e2055", - "sha256:e17947a48a5b9f632fe0c72682fc797c385e451048e7dfb20139f448a074cb3e" + "sha256:8968ff12f296433028ad561c680ccc03a7cd62576d100c3f1475e058b3c11b43", + "sha256:bd0505616c0d85ebb45c6052d339c69f320d3f87fa079ab4e91a4f234a863d05" ], "index": "pypi", - "version": "==2.5.0" + "version": "==2.6.0" }, "flake8-bugbear": { "hashes": [ @@ -593,11 +585,11 @@ }, "identify": { "hashes": [ - "sha256:2179e7359471ab55729f201b3fdf7dc2778e221f868410fedcb0987b791ba552", - "sha256:2a5fdf2f5319cc357eda2550bea713a404392495961022cf2462624ce62f0f46" + "sha256:e3b7fd755b7ceee44fe22957005a92c2a085c863c2e65a6efdec35d0e06666db", + "sha256:fab0d3a3ab0d7d5f513985b0335ccccad9d61420c5216fb779237bf7edc3e5d1" ], "markers": "python_full_version >= '3.6.1'", - "version": "==2.1.0" + "version": "==2.1.2" }, "mccabe": { "hashes": [ @@ -623,11 +615,11 @@ }, "pre-commit": { "hashes": [ - "sha256:16212d1fde2bed88159287da88ff03796863854b04dc9f838a55979325a3d20e", - "sha256:399baf78f13f4de82a29b649afd74bef2c4e28eb4f021661fc7f29246e8c7a3a" + "sha256:94c82f1bf5899d56edb1d926732f4e75a7df29a0c8c092559c77420c9d62428b", + "sha256:de55c5c72ce80d79106e48beb1b54104d16495ce7f95b0c7b13d4784193a00af" ], "index": "pypi", - "version": "==2.10.1" + "version": "==2.11.1" }, "pycodestyle": { "hashes": [ diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index b13ba574..d41fcaad 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -4,7 +4,6 @@ import logging import typing as t from concurrent.futures import ThreadPoolExecutor -import aiofiles import discord from aiohttp import client_exceptions from discord.ext import commands @@ -21,6 +20,9 @@ _EXECUTOR = ThreadPoolExecutor(10) FILENAME_STRING = "{effect}_{author}.png" +with open('bot/resources/pride/gender_options.json') as f: + GENDER_OPTIONS = json.loads(f.read()) + async def in_executor(func: t.Callable, *args) -> t.Any: """Allows non-async functions to work in async functions.""" @@ -34,12 +36,6 @@ class PfpModify(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot - self.bot.loop.create_task(self.init_cog()) - - async def init_cog(self) -> None: - """Initial load from resources asynchronously.""" - async with aiofiles.open('bot/resources/pride/gender_options.json') as f: - self.GENDER_OPTIONS = json.loads(await f.read()) @commands.group() async def pfp_modify(self, ctx: commands.Context) -> None: @@ -176,7 +172,7 @@ class PfpModify(commands.Cog): """ option = option.lower() pixels = max(0, min(512, pixels)) - flag = self.GENDER_OPTIONS.get(option) + flag = GENDER_OPTIONS.get(option) if flag is None: await ctx.send("I don't have that flag!") return @@ -197,7 +193,7 @@ class PfpModify(commands.Cog): """ option = option.lower() pixels = max(0, min(512, pixels)) - flag = self.GENDER_OPTIONS.get(option) + flag = GENDER_OPTIONS.get(option) if flag is None: await ctx.send("I don't have that flag!") return @@ -221,7 +217,7 @@ class PfpModify(commands.Cog): @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(self.GENDER_OPTIONS.values())) + choices = sorted(set(GENDER_OPTIONS.values())) options = "• " + "\n• ".join(choices) embed = discord.Embed( title="I have the following flags:", -- cgit v1.2.3 From 4dbd106f3febeadcde162bcafa163ae4d07f1b12 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 14 Mar 2021 13:38:47 +0000 Subject: Remove unnecessary lambda in when getting closest colour --- bot/exts/evergreen/profile_pic_modification/_effects.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index ae606dcb..2ff92000 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -42,7 +42,7 @@ class PfpEffects: r2, g2, b2 = point return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2 - closest_colours = sorted(Colours.easter_like_colours, key=lambda point: distance(point)) + closest_colours = sorted(Colours.easter_like_colours, key=distance) r2, g2, b2 = closest_colours[0] r = (r1 + r2) // 2 g = (g1 + g2) // 2 -- cgit v1.2.3 From 3eb23d5307bbf4abc03522e3ba9903e42131bb27 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 14 Mar 2021 13:39:26 +0000 Subject: Pull the function signature back onto one line for readibility --- bot/exts/evergreen/profile_pic_modification/_effects.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index 2ff92000..9319a1b8 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -70,11 +70,7 @@ class PfpEffects: return ring @staticmethod - def pridify_effect( - image: Image, - pixels: int, - flag: str - ) -> Image: + def pridify_effect(image: Image, pixels: int, flag: str) -> Image: """Applies the pride effect to the given image.""" image = image.resize((1024, 1024)) image = PfpEffects.crop_avatar_circle(image) -- cgit v1.2.3 From 63ec14c47ef34fe48a32ecf6b51718c277714b88 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 14 Mar 2021 13:50:37 +0000 Subject: Imrpoves docstrings within the profile pic modification functions --- bot/exts/evergreen/profile_pic_modification/_effects.py | 17 ++++++++++++----- .../evergreen/profile_pic_modification/pfp_modify.py | 9 +++++++-- 2 files changed, 19 insertions(+), 7 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index 9319a1b8..a1069db7 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -10,9 +10,9 @@ from bot.constants import Colours class PfpEffects: """ - Implements various image effects. + Implements various image modifying effects, for the PfpModify cog. - All of these methods are blocking, so should be ran in threads. + All of these fuctions are slow, and blocking, so should be ran in executors. """ @staticmethod @@ -31,7 +31,7 @@ class PfpEffects: @staticmethod def closest(x: t.Tuple[int, int, int]) -> t.Tuple[int, int, int]: """ - Finds the closest easter colour to a given pixel. + Finds the closest "easter" colour to a given pixel. Returns a merge between the original colour and the closest colour. """ @@ -71,7 +71,7 @@ class PfpEffects: @staticmethod def pridify_effect(image: Image, pixels: int, flag: str) -> Image: - """Applies the pride effect to the given image.""" + """Applies the given pride effect to the given image.""" image = image.resize((1024, 1024)) image = PfpEffects.crop_avatar_circle(image) @@ -96,7 +96,14 @@ class PfpEffects: @staticmethod def easterify_effect(image: Image, overlay_image: Image = None) -> Image: - """Applies the easter effect to the given 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 RGBvalue. + + 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(( diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index d41fcaad..19f0bdf0 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -25,7 +25,12 @@ with open('bot/resources/pride/gender_options.json') as f: async def in_executor(func: t.Callable, *args) -> t.Any: - """Allows non-async functions to work in async functions.""" + """ + Runs the given synchronus 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) @@ -233,7 +238,7 @@ class PfpModify(commands.Cog): brief='Spookify an user\'s avatar.' ) async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: - """A command to print the user's spookified avatar.""" + """This "spookifies" the given user's avatar, with a random *spooky* effect.""" if user is None: user = ctx.message.author -- cgit v1.2.3 From eac2b1d79275e3bdc08fc2a8de7a28913b8ebd5b Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 21 Mar 2021 13:52:56 +0000 Subject: Don't close bot's http session when getting image --- .../profile_pic_modification/pfp_modify.py | 24 ++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 19f0bdf0..9242ff0c 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -204,20 +204,18 @@ class PfpModify(commands.Cog): return async with ctx.typing(): - async with self.bot.http_session as session: - try: - response = await session.get(url) - except client_exceptions.ClientConnectorError: - raise BadArgument("Cannot connect to provided URL!") - except client_exceptions.InvalidURL: - raise BadArgument("Invalid URL!") - - if response.status != 200: - await ctx.send("Bad response from provided URL!") - return + 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 BadArgument("Cannot connect to provided URL!") + except client_exceptions.InvalidURL: + raise BadArgument("Invalid URL!") - image_bytes = await response.read() - await self.send_pride_image(ctx, image_bytes, pixels, flag, option) + await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() async def flags(self, ctx: commands.Context) -> None: -- cgit v1.2.3 From 2ad7b496c742d275d717b2de410caebdbeaa50d4 Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 5 Apr 2021 17:16:52 +0100 Subject: Fetch member before modifying their pfp We do this as the member cache may have an outdated version of their pfp, which can lead to errors if it's removed from the Discord CDN. Co-authored-by: vcokltfre --- bot/constants.py | 1 + .../profile_pic_modification/pfp_modify.py | 65 +++++++++++++++++----- 2 files changed, 52 insertions(+), 14 deletions(-) (limited to 'bot') diff --git a/bot/constants.py b/bot/constants.py index 3ca2cda9..853ea340 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -165,6 +165,7 @@ class Colours: class Emojis: + cross_mark = "\u274C" star = "\u2B50" christmas_tree = "\U0001F384" check = "\u2611" diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 9242ff0c..c4b74d04 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -9,7 +9,7 @@ from aiohttp import client_exceptions from discord.ext import commands from discord.ext.commands.errors import BadArgument -from bot.constants import Colours +from bot.constants import Client, Colours, Emojis from bot.exts.evergreen.profile_pic_modification._effects import PfpEffects from bot.utils.extensions import invoke_help_command from bot.utils.halloween import spookifications @@ -42,6 +42,24 @@ class PfpModify(commands.Cog): def __init__(self, bot: commands.Bot) -> None: self.bot = bot + async def _fetch_member(self, member_id: int) -> t.Optional[discord.Member]: + """ + Fetches a member and handles errors. + + This helper funciton 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 delted from the Discord CDN. + """ + try: + member = await self.bot.get_guild(Client.guild).fetch_member(member_id) + except discord.errors.NotFound: + log.debug(f"Member {member_id} left the guild before we could get their pfp.") + return None + except discord.HTTPException: + log.exception(f"Exception while trying to retrieve member {member_id} from Discord.") + return None + + return member + @commands.group() async def pfp_modify(self, ctx: commands.Context) -> None: """Groups all of the pfp modifying commands to allow a single concurrency limit.""" @@ -52,10 +70,15 @@ class PfpModify(commands.Cog): async def eightbit_command(self, ctx: commands.Context) -> None: """Pixelates your avatar and changes the palette to an 8bit one.""" async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() + member = await self._fetch_member(ctx.author.id) + if not member: + ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + + image_bytes = await member.avatar_url.read() file_name = FILENAME_STRING.format( effect="eightbit_avatar", - author=ctx.author.display_name + author=member.display_name ) file = await in_executor( @@ -71,7 +94,7 @@ class PfpModify(commands.Cog): ) 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) + embed.set_footer(text=f"Made by {member.display_name}.", icon_url=member.avatar_url) await ctx.send(embed=embed, file=file) @@ -96,6 +119,11 @@ class PfpModify(commands.Cog): return args[0] async with ctx.typing(): + member = await self._fetch_member(ctx.author.id) + if not member: + ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + egg = None if colours: send_message = ctx.send @@ -106,10 +134,10 @@ class PfpModify(commands.Cog): return ctx.send = send_message # Reassigns ctx.send - image_bytes = await ctx.author.avatar_url_as(size=256).read() + image_bytes = await member.avatar_url_as(size=256).read() file_name = FILENAME_STRING.format( effect="easterified_avatar", - author=ctx.author.display_name + author=member.display_name ) file = await in_executor( @@ -125,7 +153,7 @@ class PfpModify(commands.Cog): 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=ctx.author.avatar_url) + embed.set_footer(text=f"Made by {member.display_name}.", icon_url=member.avatar_url) await ctx.send(file=file, embed=embed) @@ -183,7 +211,11 @@ class PfpModify(commands.Cog): return async with ctx.typing(): - image_bytes = await ctx.author.avatar_url.read() + member = await self._fetch_member(ctx.author.id) + if not member: + ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + image_bytes = await member.avatar_url.read() await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() @@ -235,17 +267,22 @@ class PfpModify(commands.Cog): root_aliases=('spookyavatar', 'spookify', 'savatar'), brief='Spookify an user\'s avatar.' ) - async def spooky_avatar(self, ctx: commands.Context, user: discord.Member = None) -> None: + async def spooky_avatar(self, ctx: commands.Context, member: discord.Member = None) -> None: """This "spookifies" the given user's avatar, with a random *spooky* effect.""" - if user is None: - user = ctx.message.author + if member is None: + member = ctx.author + + member = await self._fetch_member(member.id) + if not member: + ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return async with ctx.typing(): - image_bytes = await user.avatar_url.read() + image_bytes = await member.avatar_url.read() file_name = FILENAME_STRING.format( effect="spooky_avatar", - author=user.display_name + author=member.display_name ) file = await in_executor( PfpEffects.apply_effect, @@ -258,7 +295,7 @@ class PfpModify(commands.Cog): title="Is this you or am I just really paranoid?", colour=Colours.soft_red ) - embed.set_author(name=user.name, icon_url=user.avatar_url) + 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) -- cgit v1.2.3 From 422f6d50b8eeafdf2118f1d0e96c420c0b1391ce Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Thu, 8 Apr 2021 10:04:14 +0100 Subject: Refactor pfp cog to remove unnecessary params and calls Co-authored-by: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> --- bot/exts/evergreen/profile_pic_modification/_effects.py | 2 +- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py index a1069db7..e415d700 100644 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ b/bot/exts/evergreen/profile_pic_modification/_effects.py @@ -12,7 +12,7 @@ class PfpEffects: """ Implements various image modifying effects, for the PfpModify cog. - All of these fuctions are slow, and blocking, so should be ran in executors. + All of these fuctions are slow, and blocking, so they should be ran in executors. """ @staticmethod diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index c4b74d04..712631d1 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -21,7 +21,7 @@ _EXECUTOR = ThreadPoolExecutor(10) FILENAME_STRING = "{effect}_{author}.png" with open('bot/resources/pride/gender_options.json') as f: - GENDER_OPTIONS = json.loads(f.read()) + GENDER_OPTIONS = json.load(f) async def in_executor(func: t.Callable, *args) -> t.Any: @@ -98,7 +98,7 @@ class PfpModify(commands.Cog): await ctx.send(embed=embed, file=file) - @pfp_modify.command(pass_context=True, aliases=["easterify"], root_aliases=("easterify", "avatareasterify")) + @pfp_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. -- cgit v1.2.3 From c9e4916cb1cad8d43148030660575f5084710d38 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 8 Apr 2021 10:05:53 +0100 Subject: Ensure to await ctx.send() calls --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 712631d1..68d68927 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -72,7 +72,7 @@ class PfpModify(commands.Cog): async with ctx.typing(): member = await self._fetch_member(ctx.author.id) if not member: - ctx.send(f"{Emojis.cross_mark} Could not get member info.") + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return image_bytes = await member.avatar_url.read() @@ -121,7 +121,7 @@ class PfpModify(commands.Cog): async with ctx.typing(): member = await self._fetch_member(ctx.author.id) if not member: - ctx.send(f"{Emojis.cross_mark} Could not get member info.") + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return egg = None @@ -213,7 +213,7 @@ class PfpModify(commands.Cog): async with ctx.typing(): member = await self._fetch_member(ctx.author.id) if not member: - ctx.send(f"{Emojis.cross_mark} Could not get member info.") + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return image_bytes = await member.avatar_url.read() await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @@ -274,7 +274,7 @@ class PfpModify(commands.Cog): member = await self._fetch_member(member.id) if not member: - ctx.send(f"{Emojis.cross_mark} Could not get member info.") + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return async with ctx.typing(): -- cgit v1.2.3 From 70fa9a8e36d586d6fbc1690a80a598e94506883b Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 9 Apr 2021 12:52:04 -0400 Subject: Cutover of rattlesnake to lancebot This is an initial cutover of the rattlesnake internal eval to Sir Lancebot. This commit by itself will not work. This is a simple drop in of rattlesnake code so there is context as to what has changed and why. --- bot/exts/internal_eval/__init__.py | 0 bot/exts/internal_eval/internal_eval.py | 152 ++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 bot/exts/internal_eval/__init__.py create mode 100644 bot/exts/internal_eval/internal_eval.py (limited to 'bot') diff --git a/bot/exts/internal_eval/__init__.py b/bot/exts/internal_eval/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bot/exts/internal_eval/internal_eval.py b/bot/exts/internal_eval/internal_eval.py new file mode 100644 index 00000000..f6812942 --- /dev/null +++ b/bot/exts/internal_eval/internal_eval.py @@ -0,0 +1,152 @@ +import logging +import re +import textwrap +import typing + +import discord +from discord.ext import commands + +from rattlesnake.bot import Rattlesnake +from rattlesnake.constants import ADMIN_ROLES +from rattlesnake.utils import in_whitelist +from .helpers import EvalContext + +__all__ = ["InternalEval"] + +log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") + +CODEBLOCK_REGEX = re.compile(r"(^```(py(thon)?)?\n)|(```$)") + + +class InternalEval(commands.Cog): + """Top secret code evaluation for admins and owners.""" + + def __init__(self, bot: Rattlesnake): + self.bot = bot + self.locals = {} + + @staticmethod + def shorten_output( + output: str, + max_length: int = 1900, + placeholder: str = "\n[output truncated]" + ) -> str: + """ + Shorten the `output` so it's shorter than `max_length`. + There are three tactics for this, tried in the following order: + - Shorten the output on a line-by-line basis + - Shorten the output on any whitespace character + - Shorten the output solely on character count + """ + max_length = max_length - len(placeholder) + + shortened_output = [] + char_count = 0 + for line in output.split("\n"): + if char_count + len(line) > max_length: + break + shortened_output.append(line) + char_count += len(line) + 1 # account for (possible) line ending + + if shortened_output: + shortened_output.append(placeholder) + return "\n".join(shortened_output) + + shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder) + + if shortened_output.strip() == placeholder.strip(): + # `textwrap` was unable to find whitespace to shorten on, so it has + # reduced the output to just the placeholder. Let's shorten based on + # characters instead. + shortened_output = output[:max_length] + placeholder + + return shortened_output + + async def _upload_output(self, output: str) -> typing.Optional[str]: + """Upload `internal eval` output to our pastebin and return the url.""" + try: + async with self.bot.http_session.post( + "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True + ) as resp: + data = await resp.json() + + if "key" in data: + return f"https://paste.pythondiscord.com/{data['key']}" + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload `internal eval` output to paste service!") + + async def _send_output(self, ctx: commands.Context, output: str) -> None: + """Send the `internal eval` output to the command invocation context.""" + upload_message = "" + if len(output) >= 1980: + # The output is too long, let's truncate it for in-channel output and + # upload the complete output to the paste service. + url = await self._upload_output(output) + + if url: + upload_message = f"\nFull output here: {url}" + else: + upload_message = "\n:warning: Failed to upload full output!" + + output = self.shorten_output(output) + + await ctx.send(f"```py\n{output}```{upload_message}") + + async def _eval(self, ctx: commands.Context, code: str) -> None: + """Evaluate the `code` in the current evaluation context.""" + if code.startswith("exit"): + self.locals = {} + await ctx.send("The evaluation context was reset.") + return + + context_vars = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "discord": discord, + } + + eval_context = EvalContext(context_vars, self.locals) + + log.trace("Preparing the evaluation by parsing the AST of the code") + error = eval_context.prepare_eval(code) + + if error: + log.trace("The code can't be evaluated due to an error") + await ctx.send(f"```py\n{error}\n```") + return + + log.trace("Evaluate the AST we've generated for the evaluation") + new_locals = await eval_context.run_eval() + + log.trace("Updating locals with those set during evaluation") + self.locals.update(new_locals) + + log.trace("Sending the formatted output back to the context") + await self._send_output(ctx, eval_context.format_output()) + + @commands.group(name='internal', aliases=('int',)) + @in_whitelist(roles=ADMIN_ROLES) + async def internal_group(self, ctx: commands.Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @in_whitelist(roles=ADMIN_ROLES) + async def eval(self, ctx: commands.Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = CODEBLOCK_REGEX.sub("", code.strip()) + await self._eval(ctx, code) + + @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c")) + @in_whitelist(roles=ADMIN_ROLES) + async def reset(self, ctx: commands.Context) -> None: + """Run eval in a REPL-like format.""" + self.locals = {} + await ctx.send("The evaluation context was reset.") -- cgit v1.2.3 From 8ca0fd85045bf9fbb9b78a117f3a2e30ee1d1fa5 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 9 Apr 2021 12:54:59 -0400 Subject: Add helpers for internal eval Cutting over the rattlesnake helpers specifically for internal_eval. I am mirroring the rattlesnake structure as much as I can initially to ensure basic functionality before migrating functions to fit more within Sir Lancebot's file structure. --- bot/exts/internal_eval/helpers.py | 243 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 bot/exts/internal_eval/helpers.py (limited to 'bot') diff --git a/bot/exts/internal_eval/helpers.py b/bot/exts/internal_eval/helpers.py new file mode 100644 index 00000000..5c602e4d --- /dev/null +++ b/bot/exts/internal_eval/helpers.py @@ -0,0 +1,243 @@ +import ast +import collections +import contextlib +import functools +import inspect +import io +import logging +import sys +import traceback +import types +import typing + + +log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") + +# A type alias to annotate the tuples returned from `sys.exc_info()` +ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType] +Namespace = typing.Dict[str, typing.Any] + +# This will be used as an coroutine function wrapper for the code +# to be evaluated. The wrapper contains one `pass` statement which +# will be replaced with `ast` with the code that we want to have +# evaluated. +# The function redirects output and captures exceptions that were +# raised in the code we evaluate. The latter is used to provide a +# meaningful traceback to the end user. +EVAL_WRAPPER = """ +async def _eval_wrapper_function(): + try: + with contextlib.redirect_stdout(_eval_context.stdout): + pass + if '_value_last_expression' in locals(): + if inspect.isawaitable(_value_last_expression): + _value_last_expression = await _value_last_expression + _eval_context._value_last_expression = _value_last_expression + else: + _eval_context._value_last_expression = None + except Exception: + _eval_context.exc_info = sys.exc_info() + finally: + _eval_context.locals = locals() +_eval_context.function = _eval_wrapper_function +""" + + +def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: + """Format an exception caught while evaluation code by inserting lines.""" + exc_type, exc_value, tb = exc_info + stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb)) + code = code.split("\n") + + output = ["Traceback (most recent call last):"] + for frame in stack_summary: + if frame.filename == "": + line = code[frame.lineno - 1].lstrip() + + if frame.name == "_eval_wrapper_function": + name = "" + else: + name = frame.name + else: + line = frame.line + name = frame.name + + output.append( + f' File "{frame.filename}", line {frame.lineno}, in {name}\n' + f" {line}" + ) + + output.extend(traceback.format_exception_only(exc_type, exc_value)) + return "\n".join(output) + + +class EvalContext: + """ + Represents the current `internal eval` context. + The context remembers names set during earlier runs of `internal eval`. To + clear the context, use the `?internal clear` command. + """ + + def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None: + self._locals = dict(local_vars) + self.context_vars = dict(context_vars) + + self.stdout = io.StringIO() + self._value_last_expression = None + self.exc_info = None + self.code = "" + self.function = None + self.eval_tree = None + + @property + def dependencies(self) -> typing.Dict[str, typing.Any]: + """ + Return a mapping of the dependencies for the wrapper function. + By using a property descriptor, the mapping can't be accidentally + mutated during evaluation. This ensures the dependencies are always + available. + """ + return { + "print": functools.partial(print, file=self.stdout), + "contextlib": contextlib, + "inspect": inspect, + "sys": sys, + "_eval_context": self, + "_": self._value_last_expression, + } + + @property + def locals(self) -> typing.Dict[str, typing.Any]: + """Return a mapping of names->values needed for evaluation.""" + return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)} + + @locals.setter + def locals(self, locals_: typing.Dict[str, typing.Any]) -> None: + """Update the contextual mapping of names to values.""" + log.trace(f"Updating {self._locals} with {locals_}") + self._locals.update(locals_) + + def prepare_eval(self, code: str) -> typing.Optional[str]: + """Prepare an evaluation by processing the code and setting up the context.""" + self.code = code + + if not self.code: + log.debug("No code was attached to the evaluation command") + return "[No code detected]" + + try: + code_tree = ast.parse(code, filename="") + except SyntaxError: + log.debug("Got a SyntaxError while parsing the eval code") + return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) + + log.trace("Parsing the AST to see if there's a trailing expression we need to capture") + code_tree = CaptureLastExpression(code_tree).capture() + + log.trace("Wrapping the AST in the AST of the wrapper coroutine") + eval_tree = WrapEvalCodeTree(code_tree).wrap() + + self.eval_tree = eval_tree + return None + + async def run_eval(self) -> Namespace: + """Run the evaluation and return the updated locals.""" + log.trace("Compiling the AST to bytecode using `exec` mode") + compiled_code = compile(self.eval_tree, filename="", mode="exec") + + log.trace("Executing the compiled code with the desired namespace environment") + exec(compiled_code, self.locals) # noqa: B102,S102 + + log.trace("Awaiting the created evaluation wrapper coroutine.") + await self.function() + + log.trace("Returning the updated captured locals.") + return self._locals + + def format_output(self) -> str: + """Format the output of the most recent evaluation.""" + output = [] + + log.trace(f"Getting output from stdout `{id(self.stdout)}`") + stdout_text = self.stdout.getvalue() + if stdout_text: + log.trace("Appending output captured from stdout/print") + output.append(stdout_text) + + if self._value_last_expression is not None: + log.trace("Appending the output of a captured trialing expression") + output.append(f"[Captured] {self._value_last_expression!r}") + + if self.exc_info: + log.trace("Appending exception information") + output.append(format_internal_eval_exception(self.exc_info, self.code)) + + log.trace(f"Generated output: {output!r}") + return "\n".join(output) or "[No output]" + + +class WrapEvalCodeTree(ast.NodeTransformer): + """Wraps the AST of eval code with the wrapper function.""" + + def __init__(self, eval_code_tree: ast.AST, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.eval_code_tree = eval_code_tree + + # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping + self.wrapper = ast.parse(EVAL_WRAPPER, filename="") + + def wrap(self) -> ast.AST: + """Wrap the tree of the code by the tree of the wrapper function.""" + new_tree = self.visit(self.wrapper) + return ast.fix_missing_locations(new_tree) + + def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]: # noqa: N802 + """ + Replace the `_ast.Pass` node in the wrapper function by the eval AST. + This method works on the assumption that there's a single `pass` + statement in the wrapper function. + """ + return list(ast.iter_child_nodes(self.eval_code_tree)) + + +class CaptureLastExpression(ast.NodeTransformer): + """Captures the return value from a loose expression.""" + + def __init__(self, tree: ast.AST, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.tree = tree + self.last_node = list(ast.iter_child_nodes(tree))[-1] + + def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]: # noqa: N802 + """ + Replace the Expr node that is last child node of Module with an assignment. + We use an assignment to capture the value of the last node, if it's a loose + Expr node. Normally, the value of an Expr node is lost, meaning we don't get + the output of such a last "loose" expression. By assigning it a name, we can + retrieve it for our output. + """ + if node is not self.last_node: + return node + + log.trace("Found a trailing last expression in the evaluation code") + + log.trace("Creating assignment statement with trailing expression as the right-hand side") + right_hand_side = list(ast.iter_child_nodes(node))[0] + + assignment = ast.Assign( + targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], + value=right_hand_side, + lineno=node.lineno, + col_offset=0, + ) + ast.fix_missing_locations(assignment) + return assignment + + def capture(self) -> ast.AST: + """Capture the value of the last expression with an assignment.""" + if not isinstance(self.last_node, ast.Expr): + # We only have to replace a node if the very last node is an Expr node + return self.tree + + new_tree = self.visit(self.tree) + return ast.fix_missing_locations(new_tree) -- cgit v1.2.3 From 80a9a40b3c27376657486cf183d4c0c7d4e9880f Mon Sep 17 00:00:00 2001 From: janine9vn Date: Fri, 9 Apr 2021 13:41:45 -0400 Subject: Realigned to SirLancebot Structure The code for rattlesnakes's internal eval was aligned to Sir Lancebot's structure. It was mostly renaming rattlesnake to bot and changing how some of the imports were setup as. It also included changing the __init__.py to match the Sir Lancebot cog structure. Additionally, the whitelist check has been significantly simplified to only be a role check for the admin role. The rattlesnake implementation had a more robust `in_whitelist` decorator, so it may be worth investigating adding that in if we see fit. For now, it's a simple `with_role` decorator check. The name of the cog file itself was changed to include an underscore to sidestep what I think was a namespace collision that would prevent the setup function from properly running. --- bot/exts/internal_eval/__init__.py | 10 ++ bot/exts/internal_eval/_helpers.py | 243 +++++++++++++++++++++++++++++++ bot/exts/internal_eval/_internal_eval.py | 152 +++++++++++++++++++ 3 files changed, 405 insertions(+) create mode 100644 bot/exts/internal_eval/_helpers.py create mode 100644 bot/exts/internal_eval/_internal_eval.py (limited to 'bot') diff --git a/bot/exts/internal_eval/__init__.py b/bot/exts/internal_eval/__init__.py index e69de29b..695fa74d 100644 --- a/bot/exts/internal_eval/__init__.py +++ b/bot/exts/internal_eval/__init__.py @@ -0,0 +1,10 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Set up the Internal Eval extension.""" + # Import the Cog at runtime to prevent side effects like defining + # RedisCache instances too early. + from ._internal_eval import InternalEval + + bot.add_cog(InternalEval(bot)) diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py new file mode 100644 index 00000000..5c602e4d --- /dev/null +++ b/bot/exts/internal_eval/_helpers.py @@ -0,0 +1,243 @@ +import ast +import collections +import contextlib +import functools +import inspect +import io +import logging +import sys +import traceback +import types +import typing + + +log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") + +# A type alias to annotate the tuples returned from `sys.exc_info()` +ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType] +Namespace = typing.Dict[str, typing.Any] + +# This will be used as an coroutine function wrapper for the code +# to be evaluated. The wrapper contains one `pass` statement which +# will be replaced with `ast` with the code that we want to have +# evaluated. +# The function redirects output and captures exceptions that were +# raised in the code we evaluate. The latter is used to provide a +# meaningful traceback to the end user. +EVAL_WRAPPER = """ +async def _eval_wrapper_function(): + try: + with contextlib.redirect_stdout(_eval_context.stdout): + pass + if '_value_last_expression' in locals(): + if inspect.isawaitable(_value_last_expression): + _value_last_expression = await _value_last_expression + _eval_context._value_last_expression = _value_last_expression + else: + _eval_context._value_last_expression = None + except Exception: + _eval_context.exc_info = sys.exc_info() + finally: + _eval_context.locals = locals() +_eval_context.function = _eval_wrapper_function +""" + + +def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: + """Format an exception caught while evaluation code by inserting lines.""" + exc_type, exc_value, tb = exc_info + stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb)) + code = code.split("\n") + + output = ["Traceback (most recent call last):"] + for frame in stack_summary: + if frame.filename == "": + line = code[frame.lineno - 1].lstrip() + + if frame.name == "_eval_wrapper_function": + name = "" + else: + name = frame.name + else: + line = frame.line + name = frame.name + + output.append( + f' File "{frame.filename}", line {frame.lineno}, in {name}\n' + f" {line}" + ) + + output.extend(traceback.format_exception_only(exc_type, exc_value)) + return "\n".join(output) + + +class EvalContext: + """ + Represents the current `internal eval` context. + The context remembers names set during earlier runs of `internal eval`. To + clear the context, use the `?internal clear` command. + """ + + def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None: + self._locals = dict(local_vars) + self.context_vars = dict(context_vars) + + self.stdout = io.StringIO() + self._value_last_expression = None + self.exc_info = None + self.code = "" + self.function = None + self.eval_tree = None + + @property + def dependencies(self) -> typing.Dict[str, typing.Any]: + """ + Return a mapping of the dependencies for the wrapper function. + By using a property descriptor, the mapping can't be accidentally + mutated during evaluation. This ensures the dependencies are always + available. + """ + return { + "print": functools.partial(print, file=self.stdout), + "contextlib": contextlib, + "inspect": inspect, + "sys": sys, + "_eval_context": self, + "_": self._value_last_expression, + } + + @property + def locals(self) -> typing.Dict[str, typing.Any]: + """Return a mapping of names->values needed for evaluation.""" + return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)} + + @locals.setter + def locals(self, locals_: typing.Dict[str, typing.Any]) -> None: + """Update the contextual mapping of names to values.""" + log.trace(f"Updating {self._locals} with {locals_}") + self._locals.update(locals_) + + def prepare_eval(self, code: str) -> typing.Optional[str]: + """Prepare an evaluation by processing the code and setting up the context.""" + self.code = code + + if not self.code: + log.debug("No code was attached to the evaluation command") + return "[No code detected]" + + try: + code_tree = ast.parse(code, filename="") + except SyntaxError: + log.debug("Got a SyntaxError while parsing the eval code") + return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) + + log.trace("Parsing the AST to see if there's a trailing expression we need to capture") + code_tree = CaptureLastExpression(code_tree).capture() + + log.trace("Wrapping the AST in the AST of the wrapper coroutine") + eval_tree = WrapEvalCodeTree(code_tree).wrap() + + self.eval_tree = eval_tree + return None + + async def run_eval(self) -> Namespace: + """Run the evaluation and return the updated locals.""" + log.trace("Compiling the AST to bytecode using `exec` mode") + compiled_code = compile(self.eval_tree, filename="", mode="exec") + + log.trace("Executing the compiled code with the desired namespace environment") + exec(compiled_code, self.locals) # noqa: B102,S102 + + log.trace("Awaiting the created evaluation wrapper coroutine.") + await self.function() + + log.trace("Returning the updated captured locals.") + return self._locals + + def format_output(self) -> str: + """Format the output of the most recent evaluation.""" + output = [] + + log.trace(f"Getting output from stdout `{id(self.stdout)}`") + stdout_text = self.stdout.getvalue() + if stdout_text: + log.trace("Appending output captured from stdout/print") + output.append(stdout_text) + + if self._value_last_expression is not None: + log.trace("Appending the output of a captured trialing expression") + output.append(f"[Captured] {self._value_last_expression!r}") + + if self.exc_info: + log.trace("Appending exception information") + output.append(format_internal_eval_exception(self.exc_info, self.code)) + + log.trace(f"Generated output: {output!r}") + return "\n".join(output) or "[No output]" + + +class WrapEvalCodeTree(ast.NodeTransformer): + """Wraps the AST of eval code with the wrapper function.""" + + def __init__(self, eval_code_tree: ast.AST, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.eval_code_tree = eval_code_tree + + # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping + self.wrapper = ast.parse(EVAL_WRAPPER, filename="") + + def wrap(self) -> ast.AST: + """Wrap the tree of the code by the tree of the wrapper function.""" + new_tree = self.visit(self.wrapper) + return ast.fix_missing_locations(new_tree) + + def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]: # noqa: N802 + """ + Replace the `_ast.Pass` node in the wrapper function by the eval AST. + This method works on the assumption that there's a single `pass` + statement in the wrapper function. + """ + return list(ast.iter_child_nodes(self.eval_code_tree)) + + +class CaptureLastExpression(ast.NodeTransformer): + """Captures the return value from a loose expression.""" + + def __init__(self, tree: ast.AST, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.tree = tree + self.last_node = list(ast.iter_child_nodes(tree))[-1] + + def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]: # noqa: N802 + """ + Replace the Expr node that is last child node of Module with an assignment. + We use an assignment to capture the value of the last node, if it's a loose + Expr node. Normally, the value of an Expr node is lost, meaning we don't get + the output of such a last "loose" expression. By assigning it a name, we can + retrieve it for our output. + """ + if node is not self.last_node: + return node + + log.trace("Found a trailing last expression in the evaluation code") + + log.trace("Creating assignment statement with trailing expression as the right-hand side") + right_hand_side = list(ast.iter_child_nodes(node))[0] + + assignment = ast.Assign( + targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], + value=right_hand_side, + lineno=node.lineno, + col_offset=0, + ) + ast.fix_missing_locations(assignment) + return assignment + + def capture(self) -> ast.AST: + """Capture the value of the last expression with an assignment.""" + if not isinstance(self.last_node, ast.Expr): + # We only have to replace a node if the very last node is an Expr node + return self.tree + + new_tree = self.visit(self.tree) + return ast.fix_missing_locations(new_tree) diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py new file mode 100644 index 00000000..f7a0946b --- /dev/null +++ b/bot/exts/internal_eval/_internal_eval.py @@ -0,0 +1,152 @@ +import logging +import re +import textwrap +import typing + +import discord +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Roles +from bot.utils.decorators import with_role +from ._helpers import EvalContext + +__all__ = ["InternalEval"] + +log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") + +CODEBLOCK_REGEX = re.compile(r"(^```(py(thon)?)?\n)|(```$)") + + +class InternalEval(commands.Cog): + """Top secret code evaluation for admins and owners.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.locals = {} + + @staticmethod + def shorten_output( + output: str, + max_length: int = 1900, + placeholder: str = "\n[output truncated]" + ) -> str: + """ + Shorten the `output` so it's shorter than `max_length`. + There are three tactics for this, tried in the following order: + - Shorten the output on a line-by-line basis + - Shorten the output on any whitespace character + - Shorten the output solely on character count + """ + max_length = max_length - len(placeholder) + + shortened_output = [] + char_count = 0 + for line in output.split("\n"): + if char_count + len(line) > max_length: + break + shortened_output.append(line) + char_count += len(line) + 1 # account for (possible) line ending + + if shortened_output: + shortened_output.append(placeholder) + return "\n".join(shortened_output) + + shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder) + + if shortened_output.strip() == placeholder.strip(): + # `textwrap` was unable to find whitespace to shorten on, so it has + # reduced the output to just the placeholder. Let's shorten based on + # characters instead. + shortened_output = output[:max_length] + placeholder + + return shortened_output + + async def _upload_output(self, output: str) -> typing.Optional[str]: + """Upload `internal eval` output to our pastebin and return the url.""" + try: + async with self.bot.http_session.post( + "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True + ) as resp: + data = await resp.json() + + if "key" in data: + return f"https://paste.pythondiscord.com/{data['key']}" + except Exception: + # 400 (Bad Request) means there are too many characters + log.exception("Failed to upload `internal eval` output to paste service!") + + async def _send_output(self, ctx: commands.Context, output: str) -> None: + """Send the `internal eval` output to the command invocation context.""" + upload_message = "" + if len(output) >= 1980: + # The output is too long, let's truncate it for in-channel output and + # upload the complete output to the paste service. + url = await self._upload_output(output) + + if url: + upload_message = f"\nFull output here: {url}" + else: + upload_message = "\n:warning: Failed to upload full output!" + + output = self.shorten_output(output) + + await ctx.send(f"```py\n{output}```{upload_message}") + + async def _eval(self, ctx: commands.Context, code: str) -> None: + """Evaluate the `code` in the current evaluation context.""" + if code.startswith("exit"): + self.locals = {} + await ctx.send("The evaluation context was reset.") + return + + context_vars = { + "message": ctx.message, + "author": ctx.message.author, + "channel": ctx.channel, + "guild": ctx.guild, + "ctx": ctx, + "self": self, + "bot": self.bot, + "discord": discord, + } + + eval_context = EvalContext(context_vars, self.locals) + + log.trace("Preparing the evaluation by parsing the AST of the code") + error = eval_context.prepare_eval(code) + + if error: + log.trace("The code can't be evaluated due to an error") + await ctx.send(f"```py\n{error}\n```") + return + + log.trace("Evaluate the AST we've generated for the evaluation") + new_locals = await eval_context.run_eval() + + log.trace("Updating locals with those set during evaluation") + self.locals.update(new_locals) + + log.trace("Sending the formatted output back to the context") + await self._send_output(ctx, eval_context.format_output()) + + @commands.group(name='internal', aliases=('int',)) + @with_role(Roles.admin) + async def internal_group(self, ctx: commands.Context) -> None: + """Internal commands. Top secret!""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @internal_group.command(name='eval', aliases=('e',)) + @with_role(Roles.admin) + async def eval(self, ctx: commands.Context, *, code: str) -> None: + """Run eval in a REPL-like format.""" + code = CODEBLOCK_REGEX.sub("", code.strip()) + await self._eval(ctx, code) + + @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c")) + @with_role(Roles.admin) + async def reset(self, ctx: commands.Context) -> None: + """Run eval in a REPL-like format.""" + self.locals = {} + await ctx.send("The evaluation context was reset.") -- cgit v1.2.3 From 0ac739dc029664332f1416ba85424c3b396e5660 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Sat, 10 Apr 2021 20:17:47 -0400 Subject: Change names These were missed in a previous commit. It's a simple name change from the original files to better align with Sir Lancebot. --- bot/exts/internal_eval/helpers.py | 243 -------------------------------- bot/exts/internal_eval/internal_eval.py | 152 -------------------- 2 files changed, 395 deletions(-) delete mode 100644 bot/exts/internal_eval/helpers.py delete mode 100644 bot/exts/internal_eval/internal_eval.py (limited to 'bot') diff --git a/bot/exts/internal_eval/helpers.py b/bot/exts/internal_eval/helpers.py deleted file mode 100644 index 5c602e4d..00000000 --- a/bot/exts/internal_eval/helpers.py +++ /dev/null @@ -1,243 +0,0 @@ -import ast -import collections -import contextlib -import functools -import inspect -import io -import logging -import sys -import traceback -import types -import typing - - -log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") - -# A type alias to annotate the tuples returned from `sys.exc_info()` -ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType] -Namespace = typing.Dict[str, typing.Any] - -# This will be used as an coroutine function wrapper for the code -# to be evaluated. The wrapper contains one `pass` statement which -# will be replaced with `ast` with the code that we want to have -# evaluated. -# The function redirects output and captures exceptions that were -# raised in the code we evaluate. The latter is used to provide a -# meaningful traceback to the end user. -EVAL_WRAPPER = """ -async def _eval_wrapper_function(): - try: - with contextlib.redirect_stdout(_eval_context.stdout): - pass - if '_value_last_expression' in locals(): - if inspect.isawaitable(_value_last_expression): - _value_last_expression = await _value_last_expression - _eval_context._value_last_expression = _value_last_expression - else: - _eval_context._value_last_expression = None - except Exception: - _eval_context.exc_info = sys.exc_info() - finally: - _eval_context.locals = locals() -_eval_context.function = _eval_wrapper_function -""" - - -def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: - """Format an exception caught while evaluation code by inserting lines.""" - exc_type, exc_value, tb = exc_info - stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb)) - code = code.split("\n") - - output = ["Traceback (most recent call last):"] - for frame in stack_summary: - if frame.filename == "": - line = code[frame.lineno - 1].lstrip() - - if frame.name == "_eval_wrapper_function": - name = "" - else: - name = frame.name - else: - line = frame.line - name = frame.name - - output.append( - f' File "{frame.filename}", line {frame.lineno}, in {name}\n' - f" {line}" - ) - - output.extend(traceback.format_exception_only(exc_type, exc_value)) - return "\n".join(output) - - -class EvalContext: - """ - Represents the current `internal eval` context. - The context remembers names set during earlier runs of `internal eval`. To - clear the context, use the `?internal clear` command. - """ - - def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None: - self._locals = dict(local_vars) - self.context_vars = dict(context_vars) - - self.stdout = io.StringIO() - self._value_last_expression = None - self.exc_info = None - self.code = "" - self.function = None - self.eval_tree = None - - @property - def dependencies(self) -> typing.Dict[str, typing.Any]: - """ - Return a mapping of the dependencies for the wrapper function. - By using a property descriptor, the mapping can't be accidentally - mutated during evaluation. This ensures the dependencies are always - available. - """ - return { - "print": functools.partial(print, file=self.stdout), - "contextlib": contextlib, - "inspect": inspect, - "sys": sys, - "_eval_context": self, - "_": self._value_last_expression, - } - - @property - def locals(self) -> typing.Dict[str, typing.Any]: - """Return a mapping of names->values needed for evaluation.""" - return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)} - - @locals.setter - def locals(self, locals_: typing.Dict[str, typing.Any]) -> None: - """Update the contextual mapping of names to values.""" - log.trace(f"Updating {self._locals} with {locals_}") - self._locals.update(locals_) - - def prepare_eval(self, code: str) -> typing.Optional[str]: - """Prepare an evaluation by processing the code and setting up the context.""" - self.code = code - - if not self.code: - log.debug("No code was attached to the evaluation command") - return "[No code detected]" - - try: - code_tree = ast.parse(code, filename="") - except SyntaxError: - log.debug("Got a SyntaxError while parsing the eval code") - return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) - - log.trace("Parsing the AST to see if there's a trailing expression we need to capture") - code_tree = CaptureLastExpression(code_tree).capture() - - log.trace("Wrapping the AST in the AST of the wrapper coroutine") - eval_tree = WrapEvalCodeTree(code_tree).wrap() - - self.eval_tree = eval_tree - return None - - async def run_eval(self) -> Namespace: - """Run the evaluation and return the updated locals.""" - log.trace("Compiling the AST to bytecode using `exec` mode") - compiled_code = compile(self.eval_tree, filename="", mode="exec") - - log.trace("Executing the compiled code with the desired namespace environment") - exec(compiled_code, self.locals) # noqa: B102,S102 - - log.trace("Awaiting the created evaluation wrapper coroutine.") - await self.function() - - log.trace("Returning the updated captured locals.") - return self._locals - - def format_output(self) -> str: - """Format the output of the most recent evaluation.""" - output = [] - - log.trace(f"Getting output from stdout `{id(self.stdout)}`") - stdout_text = self.stdout.getvalue() - if stdout_text: - log.trace("Appending output captured from stdout/print") - output.append(stdout_text) - - if self._value_last_expression is not None: - log.trace("Appending the output of a captured trialing expression") - output.append(f"[Captured] {self._value_last_expression!r}") - - if self.exc_info: - log.trace("Appending exception information") - output.append(format_internal_eval_exception(self.exc_info, self.code)) - - log.trace(f"Generated output: {output!r}") - return "\n".join(output) or "[No output]" - - -class WrapEvalCodeTree(ast.NodeTransformer): - """Wraps the AST of eval code with the wrapper function.""" - - def __init__(self, eval_code_tree: ast.AST, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.eval_code_tree = eval_code_tree - - # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping - self.wrapper = ast.parse(EVAL_WRAPPER, filename="") - - def wrap(self) -> ast.AST: - """Wrap the tree of the code by the tree of the wrapper function.""" - new_tree = self.visit(self.wrapper) - return ast.fix_missing_locations(new_tree) - - def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]: # noqa: N802 - """ - Replace the `_ast.Pass` node in the wrapper function by the eval AST. - This method works on the assumption that there's a single `pass` - statement in the wrapper function. - """ - return list(ast.iter_child_nodes(self.eval_code_tree)) - - -class CaptureLastExpression(ast.NodeTransformer): - """Captures the return value from a loose expression.""" - - def __init__(self, tree: ast.AST, *args, **kwargs) -> None: - super().__init__(*args, **kwargs) - self.tree = tree - self.last_node = list(ast.iter_child_nodes(tree))[-1] - - def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]: # noqa: N802 - """ - Replace the Expr node that is last child node of Module with an assignment. - We use an assignment to capture the value of the last node, if it's a loose - Expr node. Normally, the value of an Expr node is lost, meaning we don't get - the output of such a last "loose" expression. By assigning it a name, we can - retrieve it for our output. - """ - if node is not self.last_node: - return node - - log.trace("Found a trailing last expression in the evaluation code") - - log.trace("Creating assignment statement with trailing expression as the right-hand side") - right_hand_side = list(ast.iter_child_nodes(node))[0] - - assignment = ast.Assign( - targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())], - value=right_hand_side, - lineno=node.lineno, - col_offset=0, - ) - ast.fix_missing_locations(assignment) - return assignment - - def capture(self) -> ast.AST: - """Capture the value of the last expression with an assignment.""" - if not isinstance(self.last_node, ast.Expr): - # We only have to replace a node if the very last node is an Expr node - return self.tree - - new_tree = self.visit(self.tree) - return ast.fix_missing_locations(new_tree) diff --git a/bot/exts/internal_eval/internal_eval.py b/bot/exts/internal_eval/internal_eval.py deleted file mode 100644 index f6812942..00000000 --- a/bot/exts/internal_eval/internal_eval.py +++ /dev/null @@ -1,152 +0,0 @@ -import logging -import re -import textwrap -import typing - -import discord -from discord.ext import commands - -from rattlesnake.bot import Rattlesnake -from rattlesnake.constants import ADMIN_ROLES -from rattlesnake.utils import in_whitelist -from .helpers import EvalContext - -__all__ = ["InternalEval"] - -log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") - -CODEBLOCK_REGEX = re.compile(r"(^```(py(thon)?)?\n)|(```$)") - - -class InternalEval(commands.Cog): - """Top secret code evaluation for admins and owners.""" - - def __init__(self, bot: Rattlesnake): - self.bot = bot - self.locals = {} - - @staticmethod - def shorten_output( - output: str, - max_length: int = 1900, - placeholder: str = "\n[output truncated]" - ) -> str: - """ - Shorten the `output` so it's shorter than `max_length`. - There are three tactics for this, tried in the following order: - - Shorten the output on a line-by-line basis - - Shorten the output on any whitespace character - - Shorten the output solely on character count - """ - max_length = max_length - len(placeholder) - - shortened_output = [] - char_count = 0 - for line in output.split("\n"): - if char_count + len(line) > max_length: - break - shortened_output.append(line) - char_count += len(line) + 1 # account for (possible) line ending - - if shortened_output: - shortened_output.append(placeholder) - return "\n".join(shortened_output) - - shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder) - - if shortened_output.strip() == placeholder.strip(): - # `textwrap` was unable to find whitespace to shorten on, so it has - # reduced the output to just the placeholder. Let's shorten based on - # characters instead. - shortened_output = output[:max_length] + placeholder - - return shortened_output - - async def _upload_output(self, output: str) -> typing.Optional[str]: - """Upload `internal eval` output to our pastebin and return the url.""" - try: - async with self.bot.http_session.post( - "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True - ) as resp: - data = await resp.json() - - if "key" in data: - return f"https://paste.pythondiscord.com/{data['key']}" - except Exception: - # 400 (Bad Request) means there are too many characters - log.exception("Failed to upload `internal eval` output to paste service!") - - async def _send_output(self, ctx: commands.Context, output: str) -> None: - """Send the `internal eval` output to the command invocation context.""" - upload_message = "" - if len(output) >= 1980: - # The output is too long, let's truncate it for in-channel output and - # upload the complete output to the paste service. - url = await self._upload_output(output) - - if url: - upload_message = f"\nFull output here: {url}" - else: - upload_message = "\n:warning: Failed to upload full output!" - - output = self.shorten_output(output) - - await ctx.send(f"```py\n{output}```{upload_message}") - - async def _eval(self, ctx: commands.Context, code: str) -> None: - """Evaluate the `code` in the current evaluation context.""" - if code.startswith("exit"): - self.locals = {} - await ctx.send("The evaluation context was reset.") - return - - context_vars = { - "message": ctx.message, - "author": ctx.message.author, - "channel": ctx.channel, - "guild": ctx.guild, - "ctx": ctx, - "self": self, - "bot": self.bot, - "discord": discord, - } - - eval_context = EvalContext(context_vars, self.locals) - - log.trace("Preparing the evaluation by parsing the AST of the code") - error = eval_context.prepare_eval(code) - - if error: - log.trace("The code can't be evaluated due to an error") - await ctx.send(f"```py\n{error}\n```") - return - - log.trace("Evaluate the AST we've generated for the evaluation") - new_locals = await eval_context.run_eval() - - log.trace("Updating locals with those set during evaluation") - self.locals.update(new_locals) - - log.trace("Sending the formatted output back to the context") - await self._send_output(ctx, eval_context.format_output()) - - @commands.group(name='internal', aliases=('int',)) - @in_whitelist(roles=ADMIN_ROLES) - async def internal_group(self, ctx: commands.Context) -> None: - """Internal commands. Top secret!""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @internal_group.command(name='eval', aliases=('e',)) - @in_whitelist(roles=ADMIN_ROLES) - async def eval(self, ctx: commands.Context, *, code: str) -> None: - """Run eval in a REPL-like format.""" - code = CODEBLOCK_REGEX.sub("", code.strip()) - await self._eval(ctx, code) - - @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c")) - @in_whitelist(roles=ADMIN_ROLES) - async def reset(self, ctx: commands.Context) -> None: - """Run eval in a REPL-like format.""" - self.locals = {} - await ctx.send("The evaluation context was reset.") -- cgit v1.2.3 From 13be6262e6048790062be9dd7daf178fb8b8d0e5 Mon Sep 17 00:00:00 2001 From: janine9vn Date: Sat, 10 Apr 2021 20:23:45 -0400 Subject: Update codeblock regex The snekbox implementation of the codeblock regex was incorporated. This now correctly parses the `code` and ``code`` markdown discord allows. You can also use multiple code blocks with text interrupting it and it will process the different code blocks as one continuous code block. --- bot/exts/internal_eval/_internal_eval.py | 37 +++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index f7a0946b..45bfbdc3 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -17,6 +17,23 @@ log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") CODEBLOCK_REGEX = re.compile(r"(^```(py(thon)?)?\n)|(```$)") +FORMATTED_CODE_REGEX = re.compile( + r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)", # match the exact same delimiter from the start again + re.DOTALL | re.IGNORECASE # "." also matches newlines, case insensitive +) + +RAW_CODE_REGEX = re.compile( + r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P.*?)" # extract all the rest as code + r"\s*$", # any trailing whitespace until the end of the string + re.DOTALL # "." also matches newlines +) + class InternalEval(commands.Cog): """Top secret code evaluation for admins and owners.""" @@ -141,7 +158,25 @@ class InternalEval(commands.Cog): @with_role(Roles.admin) async def eval(self, ctx: commands.Context, *, code: str) -> None: """Run eval in a REPL-like format.""" - code = CODEBLOCK_REGEX.sub("", code.strip()) + + if match := list(FORMATTED_CODE_REGEX.finditer(code)): + blocks = [block for block in match if block.group("block")] + + if len(blocks) > 1: + code = '\n'.join(block.group("code") for block in blocks) + info = "several code blocks" + else: + match = match[0] if len(blocks) == 0 else blocks[0] + code, block, lang, delim = match.group("code", "block", "lang", "delim") + if block: + info = (f"'{lang}' highlighted" if lang else "plain") + " code block" + else: + info = f"{delim}-enclosed inline code" + else: + code = RAW_CODE_REGEX.fullmatch(code).group("code") + info = "unformatted or badly formatted code" + + code = textwrap.dedent(code) await self._eval(ctx, code) @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c")) -- cgit v1.2.3 From e946b50c42412a08c823a89e087bd8c67c20a85b Mon Sep 17 00:00:00 2001 From: janine9vn Date: Sat, 10 Apr 2021 20:49:41 -0400 Subject: Removed rogue variable I'm better than this I swear. I can lint before I commit. Don't tell lemon. --- bot/exts/internal_eval/_internal_eval.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 45bfbdc3..6f29a661 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -164,17 +164,12 @@ class InternalEval(commands.Cog): if len(blocks) > 1: code = '\n'.join(block.group("code") for block in blocks) - info = "several code blocks" else: match = match[0] if len(blocks) == 0 else blocks[0] code, block, lang, delim = match.group("code", "block", "lang", "delim") - if block: - info = (f"'{lang}' highlighted" if lang else "plain") + " code block" - else: - info = f"{delim}-enclosed inline code" + else: code = RAW_CODE_REGEX.fullmatch(code).group("code") - info = "unformatted or badly formatted code" code = textwrap.dedent(code) await self._eval(ctx, code) -- cgit v1.2.3 From 341908df150b6129055c970c7d2a8d36d76a4bfe Mon Sep 17 00:00:00 2001 From: janine9vn Date: Sat, 10 Apr 2021 21:07:48 -0400 Subject: Blind Fixes at Linting Both my pre-commit and flake8 runs are telling me everything is fine and it's all passed. Github actions is saying otherwise but isn't saying *where*. So here I am with useless linting commits. --- bot/exts/internal_eval/_helpers.py | 4 ++++ bot/exts/internal_eval/_internal_eval.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py index 5c602e4d..a8ae5bef 100644 --- a/bot/exts/internal_eval/_helpers.py +++ b/bot/exts/internal_eval/_helpers.py @@ -74,6 +74,7 @@ def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: class EvalContext: """ Represents the current `internal eval` context. + The context remembers names set during earlier runs of `internal eval`. To clear the context, use the `?internal clear` command. """ @@ -93,6 +94,7 @@ class EvalContext: def dependencies(self) -> typing.Dict[str, typing.Any]: """ Return a mapping of the dependencies for the wrapper function. + By using a property descriptor, the mapping can't be accidentally mutated during evaluation. This ensures the dependencies are always available. @@ -194,6 +196,7 @@ class WrapEvalCodeTree(ast.NodeTransformer): def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]: # noqa: N802 """ Replace the `_ast.Pass` node in the wrapper function by the eval AST. + This method works on the assumption that there's a single `pass` statement in the wrapper function. """ @@ -211,6 +214,7 @@ class CaptureLastExpression(ast.NodeTransformer): def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]: # noqa: N802 """ Replace the Expr node that is last child node of Module with an assignment. + We use an assignment to capture the value of the last node, if it's a loose Expr node. Normally, the value of an Expr node is lost, meaning we don't get the output of such a last "loose" expression. By assigning it a name, we can diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 6f29a661..ee438724 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -50,6 +50,7 @@ class InternalEval(commands.Cog): ) -> str: """ Shorten the `output` so it's shorter than `max_length`. + There are three tactics for this, tried in the following order: - Shorten the output on a line-by-line basis - Shorten the output on any whitespace character @@ -158,7 +159,6 @@ class InternalEval(commands.Cog): @with_role(Roles.admin) async def eval(self, ctx: commands.Context, *, code: str) -> None: """Run eval in a REPL-like format.""" - if match := list(FORMATTED_CODE_REGEX.finditer(code)): blocks = [block for block in match if block.group("block")] -- cgit v1.2.3 From 87e459836b8d3b0d624ec97fe293f994ba9c8c22 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 11 Apr 2021 11:18:28 -0400 Subject: Correct prefix usage in a doctstring Corrects the prefix for the a command in the docstring to use Lancebot's prefix. Co-authored-by: Matteo Bertucci --- bot/exts/internal_eval/_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py index a8ae5bef..8b991d98 100644 --- a/bot/exts/internal_eval/_helpers.py +++ b/bot/exts/internal_eval/_helpers.py @@ -76,7 +76,7 @@ class EvalContext: Represents the current `internal eval` context. The context remembers names set during earlier runs of `internal eval`. To - clear the context, use the `?internal clear` command. + clear the context, use the `.internal clear` command. """ def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None: -- cgit v1.2.3 From 2cc2a2e618ed019de00054c768613e3e6ba2470c Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 11 Apr 2021 11:36:56 -0400 Subject: Correct logger name Changed the initialization of the logging to pull dynamically so it can actually log correctly. --- bot/exts/internal_eval/_helpers.py | 2 +- bot/exts/internal_eval/_internal_eval.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py index 8b991d98..bd36520d 100644 --- a/bot/exts/internal_eval/_helpers.py +++ b/bot/exts/internal_eval/_helpers.py @@ -11,7 +11,7 @@ import types import typing -log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") +log = logging.getLogger(__name__) # A type alias to annotate the tuples returned from `sys.exc_info()` ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType] diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index ee438724..198c1312 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -13,7 +13,7 @@ from ._helpers import EvalContext __all__ = ["InternalEval"] -log = logging.getLogger("rattlesnake.exts.admin_tools.internal_eval") +log = logging.getLogger(__name__) CODEBLOCK_REGEX = re.compile(r"(^```(py(thon)?)?\n)|(```$)") -- cgit v1.2.3 From fa67eebb08ec9d71d94cdeaf757cc84f33053691 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 11 Apr 2021 13:58:39 -0400 Subject: Remove unused codeblock regex With the regex sufficiently stolen from snekbox and confirmed to work, the original codeblock regex has been removed. --- bot/exts/internal_eval/_internal_eval.py | 2 -- 1 file changed, 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 198c1312..4746c6c9 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -15,8 +15,6 @@ __all__ = ["InternalEval"] log = logging.getLogger(__name__) -CODEBLOCK_REGEX = re.compile(r"(^```(py(thon)?)?\n)|(```$)") - FORMATTED_CODE_REGEX = re.compile( r"(?P(?P```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block r"(?(block)(?:(?P[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) -- cgit v1.2.3 From e4829ffe61d8eda83ff89e7b86de35fe930bb793 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 11 Apr 2021 14:06:39 -0400 Subject: Update docstring for reset Updated the docstring for `reset` to provide accurate information as to what the command does. --- bot/exts/internal_eval/_internal_eval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 4746c6c9..8e474b7d 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -175,6 +175,6 @@ class InternalEval(commands.Cog): @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c")) @with_role(Roles.admin) async def reset(self, ctx: commands.Context) -> None: - """Run eval in a REPL-like format.""" + """Reset the context and locals of the eval session.""" self.locals = {} await ctx.send("The evaluation context was reset.") -- cgit v1.2.3 From af97f53e2db9494dcf934d8b646b7d65c6713924 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 11 Apr 2021 14:12:48 -0400 Subject: Change command help format `.int` with nothing else now uses the `invoke_help_command()` utility that formats the help command much more nicely than the default version --- bot/exts/internal_eval/_internal_eval.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 8e474b7d..06626b69 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -9,6 +9,7 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import Roles from bot.utils.decorators import with_role +from bot.utils.extensions import invoke_help_command from ._helpers import EvalContext __all__ = ["InternalEval"] @@ -151,7 +152,7 @@ class InternalEval(commands.Cog): async def internal_group(self, ctx: commands.Context) -> None: """Internal commands. Top secret!""" if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) + await invoke_help_command(ctx) @internal_group.command(name='eval', aliases=('e',)) @with_role(Roles.admin) -- cgit v1.2.3 From 652f428347d2108d6c70df28c8c8130545ab9029 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Sun, 11 Apr 2021 14:16:43 -0400 Subject: Ensure output will be robust for discord markdown Added in an extra `\n` at the end of the output. Sometimes discord won't properly format the codeblock in the triple ` is not on a newline. This changes ensures that it should. --- bot/exts/internal_eval/_internal_eval.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index 06626b69..a62a7899 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -108,7 +108,7 @@ class InternalEval(commands.Cog): output = self.shorten_output(output) - await ctx.send(f"```py\n{output}```{upload_message}") + await ctx.send(f"```py\n{output}\n```{upload_message}") async def _eval(self, ctx: commands.Context, code: str) -> None: """Evaluate the `code` in the current evaluation context.""" -- cgit v1.2.3 From 442391dfbafa3b3848a030301702031f01320fdd Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 12 Apr 2021 21:40:05 +0100 Subject: Request the pfp as the right size from discord --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 68d68927..51999fb0 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -215,7 +215,7 @@ class PfpModify(commands.Cog): if not member: await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return - image_bytes = await member.avatar_url.read() + image_bytes = await member.avatar_url_as(size=1024).read() await self.send_pride_image(ctx, image_bytes, pixels, flag, option) @prideavatar.command() -- cgit v1.2.3 From b411a4522422879eee3e821f149f21636dfb56bf Mon Sep 17 00:00:00 2001 From: Chris Date: Mon, 12 Apr 2021 22:21:44 +0100 Subject: Silence matplotlib's logger --- bot/__init__.py | 1 + 1 file changed, 1 insertion(+) (limited to 'bot') diff --git a/bot/__init__.py b/bot/__init__.py index d0992912..71b7c8a3 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -60,6 +60,7 @@ if root.handlers: logging.getLogger("discord").setLevel(logging.ERROR) logging.getLogger("websockets").setLevel(logging.ERROR) logging.getLogger("PIL").setLevel(logging.ERROR) +logging.getLogger("matplotlib").setLevel(logging.ERROR) # Setup new logging configuration logging.basicConfig( -- cgit v1.2.3 From c45e26621f9ea4e6209a33541f5db996e3279ea0 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Mon, 12 Apr 2021 18:20:28 -0400 Subject: Add constants for common string filenames Added a constant for the same filenames used in several locations. Because the now-a-constant string is used in several locations this will allow for it to be updated more easily down the line. --- bot/exts/internal_eval/_helpers.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py index bd36520d..3a50b9f3 100644 --- a/bot/exts/internal_eval/_helpers.py +++ b/bot/exts/internal_eval/_helpers.py @@ -41,6 +41,8 @@ async def _eval_wrapper_function(): _eval_context.locals = locals() _eval_context.function = _eval_wrapper_function """ +INTERNAL_EVAL_FRAMENAME = "" +EVAL_WRAPPER_FUNCTION_FRAMENAME = "_eval_wrapper_function" def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: @@ -51,11 +53,11 @@ def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str: output = ["Traceback (most recent call last):"] for frame in stack_summary: - if frame.filename == "": + if frame.filename == INTERNAL_EVAL_FRAMENAME: line = code[frame.lineno - 1].lstrip() - if frame.name == "_eval_wrapper_function": - name = "" + if frame.name == EVAL_WRAPPER_FUNCTION_FRAMENAME: + name = INTERNAL_EVAL_FRAMENAME else: name = frame.name else: @@ -128,7 +130,7 @@ class EvalContext: return "[No code detected]" try: - code_tree = ast.parse(code, filename="") + code_tree = ast.parse(code, filename=INTERNAL_EVAL_FRAMENAME) except SyntaxError: log.debug("Got a SyntaxError while parsing the eval code") return "".join(traceback.format_exception(*sys.exc_info(), limit=0)) @@ -145,7 +147,7 @@ class EvalContext: async def run_eval(self) -> Namespace: """Run the evaluation and return the updated locals.""" log.trace("Compiling the AST to bytecode using `exec` mode") - compiled_code = compile(self.eval_tree, filename="", mode="exec") + compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec") log.trace("Executing the compiled code with the desired namespace environment") exec(compiled_code, self.locals) # noqa: B102,S102 @@ -186,7 +188,7 @@ class WrapEvalCodeTree(ast.NodeTransformer): self.eval_code_tree = eval_code_tree # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping - self.wrapper = ast.parse(EVAL_WRAPPER, filename="") + self.wrapper = ast.parse(EVAL_WRAPPER, filename=INTERNAL_EVAL_FRAMENAME) def wrap(self) -> ast.AST: """Wrap the tree of the code by the tree of the wrapper function.""" -- cgit v1.2.3 From c9a3cdf1e71525c307ee3385a375724a861a7b74 Mon Sep 17 00:00:00 2001 From: Janine vN Date: Mon, 12 Apr 2021 18:37:01 -0400 Subject: Remove check for `exit` to reset context This commit removes the `exit` check if someone were to use this: `.int e exit` to clear the context. The check would prevent `.int e exit()` from restarting the bot container. With the `.int reset` and `.int exit` ability to clear the context the check for `exit` to clear the context isn't necessary. --- bot/exts/internal_eval/_internal_eval.py | 5 ----- 1 file changed, 5 deletions(-) (limited to 'bot') diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py index a62a7899..757a2a1e 100644 --- a/bot/exts/internal_eval/_internal_eval.py +++ b/bot/exts/internal_eval/_internal_eval.py @@ -112,11 +112,6 @@ class InternalEval(commands.Cog): async def _eval(self, ctx: commands.Context, code: str) -> None: """Evaluate the `code` in the current evaluation context.""" - if code.startswith("exit"): - self.locals = {} - await ctx.send("The evaluation context was reset.") - return - context_vars = { "message": ctx.message, "author": ctx.message.author, -- cgit v1.2.3 From e43d311ffdc8378cc0ad7095c765c76aeea145d5 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 16 Apr 2021 14:01:17 +0100 Subject: Remove un-used sound resources --- .../spookysounds/109710__tomlija__horror-gate.mp3 | Bin 118125 -> 0 bytes .../spookysounds/126113__klankbeeld__laugh.mp3 | Bin 112365 -> 0 bytes ...ugh-original-132802-nanakisan-evil-laugh-08.mp3 | Bin 137385 -> 0 bytes .../spookysounds/14570__oscillator__ghost-fx.mp3 | Bin 135405 -> 0 bytes .../spookysounds/168650__0xmusex0__doorcreak.mp3 | Bin 162421 -> 0 bytes ...71078__klankbeeld__horror-scream-woman-long.mp3 | Bin 131625 -> 0 bytes .../193812__geoneo0__four-voices-whispering-6.mp3 | Bin 163257 -> 0 bytes ...37282__devilfish101__frantic-violin-screech.mp3 | Bin 131566 -> 0 bytes .../249686__cylon8472__cthulhu-growl.mp3 | Bin 153226 -> 0 bytes .../spookysounds/35716__analogchill__scream.mp3 | Bin 114773 -> 0 bytes ...15__inspectorj__something-evil-approaches-a.mp3 | Bin 298717 -> 0 bytes .../60571__gabemiller74__breathofdeath.mp3 | Bin 177049 -> 0 bytes .../spookysounds/Female_Monster_Growls_.mp3 | Bin 148276 -> 0 bytes .../halloween/spookysounds/Male_Zombie_Roar_.mp3 | Bin 62171 -> 0 bytes .../spookysounds/Monster_Alien_Growl_Calm_.mp3 | Bin 133651 -> 0 bytes .../spookysounds/Monster_Alien_Grunt_Hiss_.mp3 | Bin 74718 -> 0 bytes bot/resources/halloween/spookysounds/sources.txt | 41 --------------------- 17 files changed, 41 deletions(-) delete mode 100644 bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 delete mode 100644 bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 delete mode 100644 bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 delete mode 100644 bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 delete mode 100644 bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 delete mode 100644 bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 delete mode 100644 bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 delete mode 100644 bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 delete mode 100644 bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 delete mode 100644 bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 delete mode 100644 bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 delete mode 100644 bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 delete mode 100644 bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 delete mode 100644 bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 delete mode 100644 bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 delete mode 100644 bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 delete mode 100644 bot/resources/halloween/spookysounds/sources.txt (limited to 'bot') diff --git a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 deleted file mode 100644 index 495f2bd1..00000000 Binary files a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 deleted file mode 100644 index 538feabc..00000000 Binary files a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 deleted file mode 100644 index 17f66698..00000000 Binary files a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 deleted file mode 100644 index 5670657c..00000000 Binary files a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 deleted file mode 100644 index 42f9e9fd..00000000 Binary files a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 deleted file mode 100644 index 1cdb0f4d..00000000 Binary files a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 deleted file mode 100644 index 89150d57..00000000 Binary files a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 deleted file mode 100644 index b5f85f8d..00000000 Binary files a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 deleted file mode 100644 index d141f68e..00000000 Binary files a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 deleted file mode 100644 index a0614b53..00000000 Binary files a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 deleted file mode 100644 index 38374316..00000000 Binary files a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 deleted file mode 100644 index f769d9d8..00000000 Binary files a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 deleted file mode 100644 index 8b04f0f5..00000000 Binary files a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 deleted file mode 100644 index 964d685e..00000000 Binary files a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 deleted file mode 100644 index 9e643773..00000000 Binary files a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 deleted file mode 100644 index ad99cf76..00000000 Binary files a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 and /dev/null differ diff --git a/bot/resources/halloween/spookysounds/sources.txt b/bot/resources/halloween/spookysounds/sources.txt deleted file mode 100644 index 7df03c2e..00000000 --- a/bot/resources/halloween/spookysounds/sources.txt +++ /dev/null @@ -1,41 +0,0 @@ -Female_Monster_Growls_ -Male_Zombie_Roar_ -Monster_Alien_Growl_Calm_ -Monster_Alien_Grunt_Hiss_ -https://www.youtube.com/audiolibrary/soundeffects - -413315__inspectorj__something-evil-approaches-a -https://freesound.org/people/InspectorJ/sounds/413315/ - -133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08 -https://freesound.org/people/klankbeeld/sounds/133674/ - -35716__analogchill__scream -https://freesound.org/people/analogchill/sounds/35716/ - -249686__cylon8472__cthulhu-growl -https://freesound.org/people/cylon8472/sounds/249686/ - -126113__klankbeeld__laugh -https://freesound.org/people/klankbeeld/sounds/126113/ - -14570__oscillator__ghost-fx -https://freesound.org/people/oscillator/sounds/14570/ - -60571__gabemiller74__breathofdeath -https://freesound.org/people/gabemiller74/sounds/60571/ - -168650__0xmusex0__doorcreak -https://freesound.org/people/0XMUSEX0/sounds/168650/ - -193812__geoneo0__four-voices-whispering-6 -https://freesound.org/people/geoneo0/sounds/193812/ - -109710__tomlija__horror-gate -https://freesound.org/people/Tomlija/sounds/109710/ - -171078__klankbeeld__horror-scream-woman-long -https://freesound.org/people/klankbeeld/sounds/171078/ - -237282__devilfish101__frantic-violin-screech -https://freesound.org/people/devilfish101/sounds/237282/ -- cgit v1.2.3 From 2d3e1cd56eaf4902ca49f0a47a6c933484f0f1b5 Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 17 Apr 2021 12:24:17 +0100 Subject: Fix captitilization in docstring Co-authored-by: LXNN --- bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 51999fb0..77c937de 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -47,7 +47,7 @@ class PfpModify(commands.Cog): Fetches a member and handles errors. This helper funciton 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 delted from the Discord CDN. + profile picture. This can lead to errors if the image is delted from the Discord CDN. """ try: member = await self.bot.get_guild(Client.guild).fetch_member(member_id) -- cgit v1.2.3 From 9524620ddbb7d3e707258e1ba3b84b23b5e3b54a Mon Sep 17 00:00:00 2001 From: Dillon Runke <44979306+Kronifer@users.noreply.github.com> Date: Tue, 20 Apr 2021 11:12:17 -0500 Subject: Add Catify command (#694) Co-authored-by: Joe Banks Co-authored-by: hypergamer80 <79152594+hypergamer80@users.noreply.github.com> --- bot/constants.py | 5 +++ bot/exts/evergreen/catify.py | 78 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 bot/exts/evergreen/catify.py (limited to 'bot') diff --git a/bot/constants.py b/bot/constants.py index a64882db..bcbdcba0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -8,6 +8,7 @@ from typing import Dict, NamedTuple __all__ = ( "AdventOfCode", "Branding", + "Cats", "Channels", "Categories", "Client", @@ -93,6 +94,10 @@ class Branding: cycle_frequency = int(environ.get("CYCLE_FREQUENCY", 3)) # 0: never, 1: every day, 2: every other day, ... +class Cats: + cats = ["ᓚᘏᗢ", "ᘡᘏᗢ", "🐈", "ᓕᘏᗢ", "ᓇᘏᗢ", "ᓂᘏᗢ", "ᘣᘏᗢ", "ᕦᘏᗢ", "ᕂᘏᗢ"] + + class Channels(NamedTuple): advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306)) advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 607247579608121354)) diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py new file mode 100644 index 00000000..a0121403 --- /dev/null +++ b/bot/exts/evergreen/catify.py @@ -0,0 +1,78 @@ +import random +from typing import Optional + +from discord import AllowedMentions, Embed +from discord.ext import commands + +from bot.constants import Cats, Colours, NEGATIVE_REPLIES + + +class Catify(commands.Cog): + """Cog for the catify command.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + + @commands.command(aliases=["ᓚᘏᗢify", "ᓚᘏᗢ"]) + 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 nickname 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 username is: `{display_name}`") + 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): + if "cat" in name: + if random.randint(0, 5) == 5: + string_list[index] = string_list[index].replace("cat", f"**{random.choice(Cats.cats)}**") + else: + string_list[index] = string_list[index].replace("cat", random.choice(Cats.cats)) + for element in Cats.cats: + if element in name: + string_list[index] = string_list[index].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 = " ".join(string_list) + await ctx.send( + f">>> {text}", + allowed_mentions=AllowedMentions.none() + ) + + +def setup(bot: commands.Bot) -> None: + """Loads the catify cog.""" + bot.add_cog(Catify(bot)) -- cgit v1.2.3 From dc7923a78a4020a4020c42a392ff38bf3f5c35f3 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Tue, 20 Apr 2021 12:30:27 -0400 Subject: chore: Fix UnboundLocalError and discord.ForbiddenErrors in the catify command --- bot/exts/evergreen/catify.py | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py index a0121403..c409ce6c 100644 --- a/bot/exts/evergreen/catify.py +++ b/bot/exts/evergreen/catify.py @@ -1,7 +1,8 @@ import random +from contextlib import suppress from typing import Optional -from discord import AllowedMentions, Embed +from discord import AllowedMentions, Embed, Forbidden from discord.ext import commands from bot.constants import Cats, Colours, NEGATIVE_REPLIES @@ -34,8 +35,11 @@ class Catify(commands.Cog): else: display_name += f" | {random.choice(Cats.cats)}" + await ctx.send(f"Your catified username is: `{display_name}`") - await ctx.author.edit(nick=display_name) + + with suppress(Forbidden): + await ctx.author.edit(nick=display_name) else: if len(text) >= 1500: embed = Embed( @@ -50,27 +54,27 @@ class Catify(commands.Cog): for index, name in enumerate(string_list): if "cat" in name: if random.randint(0, 5) == 5: - string_list[index] = string_list[index].replace("cat", f"**{random.choice(Cats.cats)}**") + string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") else: - string_list[index] = string_list[index].replace("cat", random.choice(Cats.cats)) + string_list[index] = name.replace("cat", random.choice(Cats.cats)) for element in Cats.cats: if element in name: - string_list[index] = string_list[index].replace(element, "cat") + string_list[index] = name.replace(element, "cat") - string_len = len(string_list) // 3 or len(string_list) + 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)) + 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 = " ".join(string_list) - await ctx.send( - f">>> {text}", - allowed_mentions=AllowedMentions.none() - ) + text = " ".join(string_list) + await ctx.send( + f">>> {text}", + allowed_mentions=AllowedMentions.none() + ) def setup(bot: commands.Bot) -> None: -- cgit v1.2.3 From 3477069a3a2e0b5e4030602afa4a4c0e7411a7e1 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Tue, 20 Apr 2021 12:59:46 -0400 Subject: chore: lower the input to fine more cats --- bot/exts/evergreen/catify.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py index c409ce6c..262c75bd 100644 --- a/bot/exts/evergreen/catify.py +++ b/bot/exts/evergreen/catify.py @@ -52,11 +52,12 @@ class Catify(commands.Cog): string_list = text.split() for index, name in enumerate(string_list): - if "cat" in name: + name = name.lower() + if "cat" in text: if random.randint(0, 5) == 5: - string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") + string_list[index] = text.replace("cat", f"**{random.choice(Cats.cats)}**") else: - string_list[index] = name.replace("cat", random.choice(Cats.cats)) + string_list[index] = text.replace("cat", random.choice(Cats.cats)) for element in Cats.cats: if element in name: string_list[index] = name.replace(element, "cat") -- cgit v1.2.3 From 5f889e4d3f0712c0005dbbc7c3ee820cc786ec30 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Tue, 20 Apr 2021 13:05:03 -0400 Subject: fix: Use name.replace not text.replace --- bot/exts/evergreen/catify.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py index 262c75bd..88c63202 100644 --- a/bot/exts/evergreen/catify.py +++ b/bot/exts/evergreen/catify.py @@ -53,11 +53,11 @@ class Catify(commands.Cog): string_list = text.split() for index, name in enumerate(string_list): name = name.lower() - if "cat" in text: + if "cat" in name: if random.randint(0, 5) == 5: - string_list[index] = text.replace("cat", f"**{random.choice(Cats.cats)}**") + string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**") else: - string_list[index] = text.replace("cat", random.choice(Cats.cats)) + 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") -- cgit v1.2.3 From daf7dc79753d0d4482f58ddcad0ee2a7f7a244c1 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Tue, 20 Apr 2021 16:13:57 -0400 Subject: chore: use 'nickname' and 'display name' in the right places and use allowed_mentions --- bot/exts/evergreen/catify.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py index 88c63202..ae8d54b6 100644 --- a/bot/exts/evergreen/catify.py +++ b/bot/exts/evergreen/catify.py @@ -27,7 +27,10 @@ class Catify(commands.Cog): if len(display_name) > 26: embed = Embed( title=random.choice(NEGATIVE_REPLIES), - description="Your nickname is too long to be catified! Please change it to be under 26 characters.", + 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) @@ -36,7 +39,7 @@ class Catify(commands.Cog): else: display_name += f" | {random.choice(Cats.cats)}" - await ctx.send(f"Your catified username is: `{display_name}`") + await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none()) with suppress(Forbidden): await ctx.author.edit(nick=display_name) -- cgit v1.2.3 From 29084d1576e1435a5a4a32071a79b6af28acd362 Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 22 Apr 2021 20:25:38 +0100 Subject: Fix errors when a subreddit has <5 posts. If a subreddit has <2 posts, the posts[1] check would fail with an IndexError. If the subreddit had less that 5 posts, then the k=5 check would also error. These changes harden the command for these edge cases. --- bot/exts/evergreen/reddit.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index 49127bea..4fdb6fca 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -54,7 +54,7 @@ class Reddit(commands.Cog): if not posts: return await ctx.send('No posts available!') - if posts[1]["data"]["over_18"] is True: + if posts[0]["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." @@ -63,7 +63,7 @@ class Reddit(commands.Cog): embed_titles = "" # Chooses k unique random elements from a population sequence or set. - random_posts = random.sample(posts, k=5) + random_posts = random.sample(posts, k=min(len(posts), 5)) # ----------------------------------------------------------- # This code below is bound of change when the emojis are added. -- cgit v1.2.3 From 9b9ab546090dc2513ac00f6b476b8b494eb5428a Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 22 Apr 2021 21:02:31 +0100 Subject: Escape invalid filename chars before saving. This would cause an issue with the embed not embedding the image due to a filename mismatch. This has been implemented by escaping all invalid filename characters before saving the file. --- .../profile_pic_modification/pfp_modify.py | 40 +++++++++++++--------- 1 file changed, 24 insertions(+), 16 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py index 77c937de..f6b1d394 100644 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py @@ -1,7 +1,9 @@ import asyncio import json import logging +import string import typing as t +import unicodedata from concurrent.futures import ThreadPoolExecutor import discord @@ -36,6 +38,23 @@ async def in_executor(func: t.Callable, *args) -> t.Any: 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 PfpModify(commands.Cog): """Various commands for users to change their own profile picture.""" @@ -76,10 +95,7 @@ class PfpModify(commands.Cog): return image_bytes = await member.avatar_url.read() - file_name = FILENAME_STRING.format( - effect="eightbit_avatar", - author=member.display_name - ) + file_name = file_safe_name("eightbit_avatar", member.display_name) file = await in_executor( PfpEffects.apply_effect, @@ -135,10 +151,7 @@ class PfpModify(commands.Cog): ctx.send = send_message # Reassigns ctx.send image_bytes = await member.avatar_url_as(size=256).read() - file_name = FILENAME_STRING.format( - effect="easterified_avatar", - author=member.display_name - ) + file_name = file_safe_name("easterified_avatar", member.display_name) file = await in_executor( PfpEffects.apply_effect, @@ -167,10 +180,7 @@ class PfpModify(commands.Cog): ) -> None: """Gets and sends the image in an embed. Used by the pride commands.""" async with ctx.typing(): - file_name = FILENAME_STRING.format( - effect="pride_avatar", - author=ctx.author.display_name - ) + file_name = file_safe_name("pride_avatar", ctx.author.display_name) file = await in_executor( PfpEffects.apply_effect, @@ -280,10 +290,8 @@ class PfpModify(commands.Cog): async with ctx.typing(): image_bytes = await member.avatar_url.read() - file_name = FILENAME_STRING.format( - effect="spooky_avatar", - author=member.display_name - ) + file_name = file_safe_name("spooky_avatar", member.display_name) + file = await in_executor( PfpEffects.apply_effect, image_bytes, -- cgit v1.2.3 From e858a3682ffe36675570508802f633046b39ebd8 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 23 Apr 2021 02:55:03 +0300 Subject: Adds Link Suppressing Helper Adds a helper to find and escape links in a message. Signed-off-by: Hassan Abouelela --- bot/utils/helpers.py | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 bot/utils/helpers.py (limited to 'bot') diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py new file mode 100644 index 00000000..74c2ccd0 --- /dev/null +++ b/bot/utils/helpers.py @@ -0,0 +1,8 @@ +import re + + +def suppress_links(message: str) -> str: + """Accepts a message that may contain links, suppresses them, and returns them.""" + for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)): + message = message.replace(link, f"<{link}>") + return message -- cgit v1.2.3 From 9e03064e9c116b0dd2bcc65b149b7ac9ee389ff3 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Fri, 23 Apr 2021 02:59:30 +0300 Subject: Suppresses Links In Commands Suppresses links in certain commands that can echo back user input. Signed-off-by: Hassan Abouelela --- bot/exts/easter/egg_decorating.py | 4 +++- bot/exts/evergreen/catify.py | 3 ++- bot/exts/evergreen/fun.py | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py index b18e6636..a847388d 100644 --- a/bot/exts/easter/egg_decorating.py +++ b/bot/exts/easter/egg_decorating.py @@ -10,6 +10,8 @@ import discord from PIL import Image from discord.ext import commands +from bot.utils import helpers + log = logging.getLogger(__name__) with open(Path("bot/resources/evergreen/html_colours.json"), encoding="utf8") as f: @@ -65,7 +67,7 @@ class EggDecorating(commands.Cog): if value: colours[idx] = discord.Colour(value) else: - invalid.append(colour) + invalid.append(helpers.suppress_links(colour)) if len(invalid) > 1: return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}") diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py index ae8d54b6..d8a7442d 100644 --- a/bot/exts/evergreen/catify.py +++ b/bot/exts/evergreen/catify.py @@ -6,6 +6,7 @@ from discord import AllowedMentions, Embed, Forbidden from discord.ext import commands from bot.constants import Cats, Colours, NEGATIVE_REPLIES +from bot.utils import helpers class Catify(commands.Cog): @@ -74,7 +75,7 @@ class Catify(commands.Cog): else: string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats)) - text = " ".join(string_list) + text = helpers.suppress_links(" ".join(string_list)) await ctx.send( f">>> {text}", allowed_mentions=AllowedMentions.none() diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py index 101725da..7152d0cb 100644 --- a/bot/exts/evergreen/fun.py +++ b/bot/exts/evergreen/fun.py @@ -11,6 +11,7 @@ from discord.ext.commands import BadArgument, Bot, Cog, Context, MessageConverte from bot import utils from bot.constants import Client, Colours, Emojis +from bot.utils import helpers log = logging.getLogger(__name__) @@ -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('> ')}" -- cgit v1.2.3 From d77986e528b977170c30efb6afc41e53425ad6df Mon Sep 17 00:00:00 2001 From: ChrisJL Date: Fri, 23 Apr 2021 12:28:37 +0100 Subject: Fix spelling of a user facing message in reddit cog Co-authored-by: Xithrius <15021300+Xithrius@users.noreply.github.com> --- bot/exts/evergreen/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py index 4fdb6fca..2be511c8 100644 --- a/bot/exts/evergreen/reddit.py +++ b/bot/exts/evergreen/reddit.py @@ -56,7 +56,7 @@ class Reddit(commands.Cog): if posts[0]["data"]["over_18"] is True: return await ctx.send( - "You cannot access this Subreddit as it is ment for those who " + "You cannot access this Subreddit as it is meant for those who " "are 18 years or older." ) -- cgit v1.2.3 From e666fb7b80a9f590257d1426c7604ff180b35e6e Mon Sep 17 00:00:00 2001 From: Salil Chincholikar Date: Tue, 27 Apr 2021 14:39:58 +0530 Subject: Reworded/fixed grammatical error --- bot/utils/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index 2b1c1b31..5e4d6330 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -1,4 +1,4 @@ class UserNotPlayingError(Exception): - """Will raised when user try to use game commands when not playing.""" + """Will be raised when the users try to use game commands when they are not playing.""" pass -- cgit v1.2.3 From 4e0210922ef8d25eeb18f080c9baab022d70d0d2 Mon Sep 17 00:00:00 2001 From: Salil Chincholikar <31334826+chincholikarsalil@users.noreply.github.com> Date: Tue, 27 Apr 2021 19:12:28 +0530 Subject: Updated bot/utils/exceptions.py Co-authored-by: Joe Banks --- bot/utils/exceptions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py index 5e4d6330..9e080759 100644 --- a/bot/utils/exceptions.py +++ b/bot/utils/exceptions.py @@ -1,4 +1,4 @@ class UserNotPlayingError(Exception): - """Will be raised when the users try to use game commands when they are not playing.""" + """Raised when users try to use game commands when they are not playing.""" pass -- cgit v1.2.3 From dade71a609d298e8bfb16fef8c8ecbd2faffe51d Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 30 Apr 2021 10:00:51 +0100 Subject: Rename pfp_modify cog and group to avatar_mod for clarity Added in aliases for ease of use too. --- bot/exts/evergreen/avatar_modification/__init__.py | 0 bot/exts/evergreen/avatar_modification/_effects.py | 139 +++++++++ .../evergreen/avatar_modification/avatar_modify.py | 315 +++++++++++++++++++++ .../evergreen/profile_pic_modification/__init__.py | 0 .../evergreen/profile_pic_modification/_effects.py | 139 --------- .../profile_pic_modification/pfp_modify.py | 315 --------------------- 6 files changed, 454 insertions(+), 454 deletions(-) create mode 100644 bot/exts/evergreen/avatar_modification/__init__.py create mode 100644 bot/exts/evergreen/avatar_modification/_effects.py create mode 100644 bot/exts/evergreen/avatar_modification/avatar_modify.py delete mode 100644 bot/exts/evergreen/profile_pic_modification/__init__.py delete mode 100644 bot/exts/evergreen/profile_pic_modification/_effects.py delete mode 100644 bot/exts/evergreen/profile_pic_modification/pfp_modify.py (limited to '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 diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py new file mode 100644 index 00000000..e415d700 --- /dev/null +++ b/bot/exts/evergreen/avatar_modification/_effects.py @@ -0,0 +1,139 @@ +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 fuctions 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: + """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, px: int) -> 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, pixels: int, flag: str) -> 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: + """ + 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, overlay_image: Image = None) -> 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 RGBvalue. + + 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 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..e1078fa0 --- /dev/null +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -0,0 +1,315 @@ +import asyncio +import json +import logging +import string +import typing as t +import unicodedata +from concurrent.futures import ThreadPoolExecutor + +import discord +from aiohttp import client_exceptions +from discord.ext import commands +from discord.ext.commands.errors import BadArgument + +from bot.constants import Client, 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" + +with open('bot/resources/pride/gender_options.json') as f: + GENDER_OPTIONS = json.load(f) + + +async def in_executor(func: t.Callable, *args) -> t.Any: + """ + Runs the given synchronus 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: commands.Bot) -> None: + self.bot = bot + + async def _fetch_member(self, member_id: int) -> t.Optional[discord.Member]: + """ + Fetches a member and handles errors. + + This helper funciton 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 delted from the Discord CDN. + """ + try: + member = await self.bot.get_guild(Client.guild).fetch_member(member_id) + except discord.errors.NotFound: + log.debug(f"Member {member_id} left the guild before we could get their pfp.") + return None + except discord.HTTPException: + log.exception(f"Exception while trying to retrieve member {member_id} from Discord.") + return None + + return member + + @commands.group(aliases=('avatar_mod', 'pfp_mod')) + 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(): + member = await self._fetch_member(ctx.author.id) + if not member: + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + + image_bytes = await member.avatar_url.read() + file_name = file_safe_name("eightbit_avatar", member.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 {member.display_name}.", icon_url=member.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(): + member = await self._fetch_member(ctx.author.id) + if not member: + await ctx.send(f"{Emojis.cross_mark} Could not get member 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 member.avatar_url_as(size=256).read() + file_name = file_safe_name("easterified_avatar", member.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 {member.display_name}.", icon_url=member.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(): + member = await self._fetch_member(ctx.author.id) + if not member: + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + image_bytes = await member.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 BadArgument("Cannot connect to provided URL!") + except client_exceptions.InvalidURL: + raise 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( + name='spookyavatar', + aliases=('savatar', 'spookify'), + root_aliases=('spookyavatar', 'spookify', 'savatar'), + brief='Spookify an user\'s avatar.' + ) + async def spooky_avatar(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 + + member = await self._fetch_member(member.id) + if not member: + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + + async with ctx.typing(): + image_bytes = await member.avatar_url.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) + + +def setup(bot: commands.Bot) -> None: + """Load the PfpModify cog.""" + bot.add_cog(AvatarModify(bot)) diff --git a/bot/exts/evergreen/profile_pic_modification/__init__.py b/bot/exts/evergreen/profile_pic_modification/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/bot/exts/evergreen/profile_pic_modification/_effects.py b/bot/exts/evergreen/profile_pic_modification/_effects.py deleted file mode 100644 index e415d700..00000000 --- a/bot/exts/evergreen/profile_pic_modification/_effects.py +++ /dev/null @@ -1,139 +0,0 @@ -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 fuctions 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: - """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, px: int) -> 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, pixels: int, flag: str) -> 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: - """ - 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, overlay_image: Image = None) -> 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 RGBvalue. - - 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 diff --git a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py deleted file mode 100644 index f6b1d394..00000000 --- a/bot/exts/evergreen/profile_pic_modification/pfp_modify.py +++ /dev/null @@ -1,315 +0,0 @@ -import asyncio -import json -import logging -import string -import typing as t -import unicodedata -from concurrent.futures import ThreadPoolExecutor - -import discord -from aiohttp import client_exceptions -from discord.ext import commands -from discord.ext.commands.errors import BadArgument - -from bot.constants import Client, Colours, Emojis -from bot.exts.evergreen.profile_pic_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" - -with open('bot/resources/pride/gender_options.json') as f: - GENDER_OPTIONS = json.load(f) - - -async def in_executor(func: t.Callable, *args) -> t.Any: - """ - Runs the given synchronus 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 PfpModify(commands.Cog): - """Various commands for users to change their own profile picture.""" - - def __init__(self, bot: commands.Bot) -> None: - self.bot = bot - - async def _fetch_member(self, member_id: int) -> t.Optional[discord.Member]: - """ - Fetches a member and handles errors. - - This helper funciton 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 delted from the Discord CDN. - """ - try: - member = await self.bot.get_guild(Client.guild).fetch_member(member_id) - except discord.errors.NotFound: - log.debug(f"Member {member_id} left the guild before we could get their pfp.") - return None - except discord.HTTPException: - log.exception(f"Exception while trying to retrieve member {member_id} from Discord.") - return None - - return member - - @commands.group() - async def pfp_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) - - @pfp_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(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") - return - - image_bytes = await member.avatar_url.read() - file_name = file_safe_name("eightbit_avatar", member.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 {member.display_name}.", icon_url=member.avatar_url) - - await ctx.send(embed=embed, file=file) - - @pfp_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(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member 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 member.avatar_url_as(size=256).read() - file_name = file_safe_name("easterified_avatar", member.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 {member.display_name}.", icon_url=member.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) - - @pfp_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(): - member = await self._fetch_member(ctx.author.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") - return - image_bytes = await member.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 BadArgument("Cannot connect to provided URL!") - except client_exceptions.InvalidURL: - raise 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) - - @pfp_modify.command( - name='spookyavatar', - aliases=('savatar', 'spookify'), - root_aliases=('spookyavatar', 'spookify', 'savatar'), - brief='Spookify an user\'s avatar.' - ) - async def spooky_avatar(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 - - member = await self._fetch_member(member.id) - if not member: - await ctx.send(f"{Emojis.cross_mark} Could not get member info.") - return - - async with ctx.typing(): - image_bytes = await member.avatar_url.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) - - -def setup(bot: commands.Bot) -> None: - """Load the PfpModify cog.""" - bot.add_cog(PfpModify(bot)) -- cgit v1.2.3 From 1e1e8aec0d943cecda46fa22300bab3cae108263 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 30 Apr 2021 10:04:07 +0100 Subject: Consistant use of double quotes and tuples in avatar_mod cog --- .../evergreen/avatar_modification/avatar_modify.py | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index e1078fa0..095de306 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -22,7 +22,7 @@ _EXECUTOR = ThreadPoolExecutor(10) FILENAME_STRING = "{effect}_{author}.png" -with open('bot/resources/pride/gender_options.json') as f: +with open("bot/resources/pride/gender_options.json") as f: GENDER_OPTIONS = json.load(f) @@ -48,10 +48,10 @@ def file_safe_name(effect: str, display_name: str) -> str: file_name = file_name.replace(" ", "_") # Normalize unicode characters - cleaned_filename = unicodedata.normalize('NFKD', file_name).encode('ASCII', 'ignore').decode() + 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) + cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars) return cleaned_filename @@ -79,7 +79,7 @@ class AvatarModify(commands.Cog): return member - @commands.group(aliases=('avatar_mod', 'pfp_mod')) + @commands.group(aliases=("avatar_mod", "pfp_mod")) 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: @@ -114,7 +114,7 @@ class AvatarModify(commands.Cog): await ctx.send(embed=embed, file=file) - @avatar_modify.command(aliases=["easterify"], root_aliases=("easterify", "avatareasterify")) + @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. @@ -200,7 +200,7 @@ class AvatarModify(commands.Cog): await ctx.send(file=file, embed=embed) @avatar_modify.group( - aliases=["avatarpride", "pridepfp", "prideprofile"], + aliases=("avatarpride", "pridepfp", "prideprofile"), root_aliases=("prideavatar", "avatarpride", "pridepfp", "prideprofile"), invoke_without_command=True ) @@ -272,12 +272,11 @@ class AvatarModify(commands.Cog): await ctx.send(embed=embed) @avatar_modify.command( - name='spookyavatar', - aliases=('savatar', 'spookify'), - root_aliases=('spookyavatar', 'spookify', 'savatar'), - brief='Spookify an user\'s avatar.' + aliases=("savatar", "spookify"), + root_aliases=("spookyavatar", "spookify", "savatar"), + brief="Spookify an user's avatar." ) - async def spooky_avatar(self, ctx: commands.Context, member: discord.Member = None) -> None: + 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 -- cgit v1.2.3 From 2bbbef35ebbb01e7671250f58c4a8e7543942b08 Mon Sep 17 00:00:00 2001 From: Chris Date: Fri, 30 Apr 2021 10:09:37 +0100 Subject: Add non-underscore aliases for lazy people like me :) --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 095de306..a111c480 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -79,7 +79,7 @@ class AvatarModify(commands.Cog): return member - @commands.group(aliases=("avatar_mod", "pfp_mod")) + @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: -- cgit v1.2.3 From 477a4d7e095c2cb5ca51339b3e75b287338e3e92 Mon Sep 17 00:00:00 2001 From: vcokltfre Date: Fri, 30 Apr 2021 17:32:53 +0100 Subject: fix: remove () from list of safe filename chars --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index a111c480..0baee8b2 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -40,7 +40,7 @@ async def in_executor(func: t.Callable, *args) -> t.Any: 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}" + valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}" file_name = FILENAME_STRING.format(effect=effect, author=display_name) -- cgit v1.2.3 From cf110bc4c8b0713291174ac7cfb3730d0ff226a7 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Fri, 30 Apr 2021 14:08:14 -0400 Subject: feat: Add the .mosaic command --- bot/exts/evergreen/avatar_modification/_effects.py | 158 ++++++++++++++++++++- .../evergreen/avatar_modification/avatar_modify.py | 50 ++++++- 2 files changed, 202 insertions(+), 6 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py index e415d700..d2370b4b 100644 --- a/bot/exts/evergreen/avatar_modification/_effects.py +++ b/bot/exts/evergreen/avatar_modification/_effects.py @@ -1,3 +1,5 @@ +import math +import random import typing as t from io import BytesIO from pathlib import Path @@ -51,7 +53,7 @@ class PfpEffects: return r, g, b @staticmethod - def crop_avatar_circle(avatar: Image) -> Image: + 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) @@ -60,7 +62,7 @@ class PfpEffects: return avatar @staticmethod - def crop_ring(ring: Image, px: int) -> Image: + 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) @@ -70,7 +72,7 @@ class PfpEffects: return ring @staticmethod - def pridify_effect(image: Image, pixels: int, flag: str) -> Image: + 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) @@ -83,7 +85,7 @@ class PfpEffects: return image @staticmethod - def eight_bitify_effect(image: Image) -> Image: + def eight_bitify_effect(image: Image.Image) -> Image.Image: """ Applies the 8bit effect to the given image. @@ -95,7 +97,7 @@ class PfpEffects: return image.quantize() @staticmethod - def easterify_effect(image: Image, overlay_image: Image = None) -> Image: + def easterify_effect(image: Image.Image, overlay_image: t.Optional[Image.Image] = None) -> Image.Image: """ Applies the easter effect to the given image. @@ -137,3 +139,149 @@ class PfpEffects: (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: + """Seperate 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 index 0baee8b2..afff125f 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -1,6 +1,7 @@ import asyncio import json import logging +import math import string import typing as t import unicodedata @@ -22,11 +23,15 @@ _EXECUTOR = ThreadPoolExecutor(10) FILENAME_STRING = "{effect}_{author}.png" +MAX_SQUARES = 10_000 + +T = t.TypeVar("T") + with open("bot/resources/pride/gender_options.json") as f: GENDER_OPTIONS = json.load(f) -async def in_executor(func: t.Callable, *args) -> t.Any: +async def in_executor(func: t.Callable[..., T], *args) -> T: """ Runs the given synchronus function `func` in an executor. @@ -308,6 +313,49 @@ class AvatarModify(commands.Cog): 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(): + if squares < 1: + raise commands.BadArgument("Squares must be a positive number") + + if not math.sqrt(squares).is_integer(): + raise commands.BadArgument("Squares must be a perfect square") + + if squares > MAX_SQUARES: + raise commands.BadArgument(f"Number of squares cannot be higher than {MAX_SQUARES:,}.") + + file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) + img_bytes = await ctx.author.avatar_url.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 = 'Here is your avatar. I think it looks a bit *puzzling*' + + embed = discord.Embed( + title=title, + description=description + ) + + 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) + def setup(bot: commands.Bot) -> None: """Load the PfpModify cog.""" -- cgit v1.2.3 From ccb7cd7bc4ab43e4d777093a14c921a64c884f6e Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Wed, 5 May 2021 11:28:01 -0400 Subject: chore: Add a 5 second cooldown per user to .catify --- bot/exts/evergreen/catify.py | 1 + 1 file changed, 1 insertion(+) (limited to 'bot') diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py index d8a7442d..a175602f 100644 --- a/bot/exts/evergreen/catify.py +++ b/bot/exts/evergreen/catify.py @@ -16,6 +16,7 @@ class Catify(commands.Cog): self.bot = bot @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. -- cgit v1.2.3 From 544a05b00758583f4594569e032c9e661406a72f Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Wed, 5 May 2021 16:50:59 -0400 Subject: chore: Fetch the member and use Colours.blue in the embed for the .mosaic command --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index afff125f..bd151d30 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -317,6 +317,11 @@ class AvatarModify(commands.Cog): 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(): + member = await self._fetch_member(ctx.author.id) + if not member: + await ctx.send(f"{Emojis.cross_mark} Could not get member info.") + return + if squares < 1: raise commands.BadArgument("Squares must be a positive number") @@ -327,7 +332,8 @@ class AvatarModify(commands.Cog): raise commands.BadArgument(f"Number of squares cannot be higher than {MAX_SQUARES:,}.") file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) - img_bytes = await ctx.author.avatar_url.read() + + img_bytes = await member.avatar_url.read() file = await in_executor( PfpEffects.mosaic_effect, @@ -348,7 +354,8 @@ class AvatarModify(commands.Cog): embed = discord.Embed( title=title, - description=description + description=description, + colour=Colours.blue ) embed.set_image(url=f'attachment://{file_name}') -- cgit v1.2.3 From 390c05383e42ba60197f5fb888cd93e867c00038 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Wed, 5 May 2021 17:17:08 -0400 Subject: chore: Prefer double quotes over single quotes --- .../evergreen/avatar_modification/avatar_modify.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index bd151d30..ca048134 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -313,7 +313,7 @@ class AvatarModify(commands.Cog): await ctx.send(file=file, embed=embed) - @avatar_modify.command(name='mosaic', root_aliases=('mosaic',)) + @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(): @@ -343,14 +343,14 @@ class AvatarModify(commands.Cog): ) if squares == 1: - title = 'Hooh... that was a lot of work' - description = 'I present to you... Yourself!' + 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:' + title = "Testing the limits I see..." + description = "What a masterpiece. :star:" else: - title = 'Your mosaic avatar' - description = 'Here is your avatar. I think it looks a bit *puzzling*' + title = "Your mosaic avatar" + description = "Here is your avatar. I think it looks a bit *puzzling*" embed = discord.Embed( title=title, @@ -358,8 +358,8 @@ class AvatarModify(commands.Cog): colour=Colours.blue ) - 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) + 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) -- cgit v1.2.3 From f6175b5cf842c7052089c05f387770fc9d91b6d0 Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Wed, 5 May 2021 17:19:32 -0400 Subject: chore: Use the name AvatarModify instead of PfpModify --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index ca048134..221bd809 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -365,5 +365,5 @@ class AvatarModify(commands.Cog): def setup(bot: commands.Bot) -> None: - """Load the PfpModify cog.""" + """Load the AvatarModify cog.""" bot.add_cog(AvatarModify(bot)) -- cgit v1.2.3 From 16b43597050b8dad922c8d93aa07c061e0512c1f Mon Sep 17 00:00:00 2001 From: ToxicKidz <78174417+ToxicKidz@users.noreply.github.com> Date: Thu, 6 May 2021 13:05:37 -0400 Subject: chore: Check if the number of squares first is bigger than the max first Co-authored-by: Anand Krishna <40204976+anand2312@users.noreply.github.com> --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 221bd809..dffe43ce 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -322,14 +322,11 @@ class AvatarModify(commands.Cog): await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return - if squares < 1: - raise commands.BadArgument("Squares must be a positive number") + if 1 <= squares <= MAX_SQUARES: + raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.") if not math.sqrt(squares).is_integer(): - raise commands.BadArgument("Squares must be a perfect square") - - if squares > MAX_SQUARES: - raise commands.BadArgument(f"Number of squares cannot be higher than {MAX_SQUARES:,}.") + raise commands.BadArgument("The number of squares must be a perfect square.") file_name = file_safe_name("mosaic_avatar", ctx.author.display_name) -- cgit v1.2.3 From 40d5a00f1b609b23a6cd77a3b7f1e4814ed7df82 Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Fri, 7 May 2021 11:01:24 -0400 Subject: chore: Get the next perfect square If the amount of squares is not a perfect square, get the next highest perfect square --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index dffe43ce..6418eaee 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -325,8 +325,10 @@ class AvatarModify(commands.Cog): if 1 <= squares <= MAX_SQUARES: raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.") - if not math.sqrt(squares).is_integer(): - raise commands.BadArgument("The number of squares must be a perfect square.") + 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) -- cgit v1.2.3 From 14fc010db6a8ac06eb356617146e9deaa540177b Mon Sep 17 00:00:00 2001 From: ToxicKidz Date: Fri, 7 May 2021 11:34:35 -0400 Subject: fix: Add a missing 'not' --- bot/exts/evergreen/avatar_modification/avatar_modify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'bot') diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py index 6418eaee..2afc3b74 100644 --- a/bot/exts/evergreen/avatar_modification/avatar_modify.py +++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py @@ -322,7 +322,7 @@ class AvatarModify(commands.Cog): await ctx.send(f"{Emojis.cross_mark} Could not get member info.") return - if 1 <= squares <= MAX_SQUARES: + 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) -- cgit v1.2.3