aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/evergreen
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/evergreen')
-rw-r--r--bot/exts/evergreen/8bitify.py55
-rw-r--r--bot/exts/evergreen/avatar_modification/__init__.py0
-rw-r--r--bot/exts/evergreen/avatar_modification/_effects.py287
-rw-r--r--bot/exts/evergreen/avatar_modification/avatar_modify.py368
-rw-r--r--bot/exts/evergreen/catify.py88
-rw-r--r--bot/exts/evergreen/fun.py3
-rw-r--r--bot/exts/evergreen/help.py4
7 files changed, 749 insertions, 56 deletions
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
deleted file mode 100644
index 7eb4d313..00000000
--- a/bot/exts/evergreen/8bitify.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from io import BytesIO
-
-import discord
-from PIL import Image
-from discord.ext import commands
-
-
-class EightBitify(commands.Cog):
- """Make your avatar 8bit!"""
-
- def __init__(self, bot: commands.Bot) -> None:
- self.bot = bot
-
- @staticmethod
- def pixelate(image: Image) -> Image:
- """Takes an image and pixelates it."""
- return image.resize((32, 32), resample=Image.NEAREST).resize((1024, 1024), resample=Image.NEAREST)
-
- @staticmethod
- def quantize(image: Image) -> Image:
- """Reduces colour palette to 256 colours."""
- return image.quantize()
-
- @commands.command(name="8bitify")
- async def eightbit_command(self, ctx: commands.Context) -> None:
- """Pixelates your avatar and changes the palette to an 8bit one."""
- async with ctx.typing():
- author = await self.bot.fetch_user(ctx.author.id)
- image_bytes = await author.avatar_url.read()
- avatar = Image.open(BytesIO(image_bytes))
- avatar = avatar.convert("RGBA").resize((1024, 1024))
-
- eightbit = self.pixelate(avatar)
- eightbit = self.quantize(eightbit)
-
- bufferedio = BytesIO()
- eightbit.save(bufferedio, format="PNG")
- bufferedio.seek(0)
-
- file = discord.File(bufferedio, filename="8bitavatar.png")
-
- embed = discord.Embed(
- title="Your 8-bit avatar",
- description='Here is your avatar. I think it looks all cool and "retro"'
- )
-
- embed.set_image(url="attachment://8bitavatar.png")
- embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.avatar_url)
-
- await ctx.send(file=file, embed=embed)
-
-
-def setup(bot: commands.Bot) -> None:
- """Cog load."""
- bot.add_cog(EightBitify(bot))
diff --git a/bot/exts/evergreen/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/__init__.py
diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py
new file mode 100644
index 00000000..d2370b4b
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/_effects.py
@@ -0,0 +1,287 @@
+import math
+import random
+import typing as t
+from io import BytesIO
+from pathlib import Path
+
+import discord
+from PIL import Image, ImageDraw, ImageOps
+
+from bot.constants import Colours
+
+
+class PfpEffects:
+ """
+ Implements various image modifying effects, for the PfpModify cog.
+
+ All of these 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) -> Image.Image:
+ """This crops the avatar given into a circle."""
+ mask = Image.new("L", avatar.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + avatar.size, fill=255)
+ avatar.putalpha(mask)
+ return avatar
+
+ @staticmethod
+ def crop_ring(ring: Image.Image, px: int) -> Image.Image:
+ """This crops the given ring into a circle."""
+ mask = Image.new("L", ring.size, 0)
+ draw = ImageDraw.Draw(mask)
+ draw.ellipse((0, 0) + ring.size, fill=255)
+ draw.ellipse((px, px, 1024-px, 1024-px), fill=0)
+ ring.putalpha(mask)
+ return ring
+
+ @staticmethod
+ def pridify_effect(image: Image.Image, pixels: int, flag: str) -> Image.Image:
+ """Applies the given pride effect to the given image."""
+ image = image.resize((1024, 1024))
+ image = PfpEffects.crop_avatar_circle(image)
+
+ ring = Image.open(Path(f"bot/resources/pride/flags/{flag}.png")).resize((1024, 1024))
+ ring = ring.convert("RGBA")
+ ring = PfpEffects.crop_ring(ring, pixels)
+
+ image.alpha_composite(ring, (0, 0))
+ return image
+
+ @staticmethod
+ def eight_bitify_effect(image: Image.Image) -> Image.Image:
+ """
+ Applies the 8bit effect to the given image.
+
+ This is done by reducing the image to 32x32 and then back up to 1024x1024.
+ We then quantize the image before returning too.
+ """
+ image = image.resize((32, 32), resample=Image.NEAREST)
+ image = image.resize((1024, 1024), resample=Image.NEAREST)
+ return image.quantize()
+
+ @staticmethod
+ def easterify_effect(image: Image.Image, overlay_image: t.Optional[Image.Image] = None) -> Image.Image:
+ """
+ Applies the easter effect to the given image.
+
+ This is done by getting the closest "easter" colour to each pixel and changing the colour
+ to the half-way 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
+
+ @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
new file mode 100644
index 00000000..2afc3b74
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py
@@ -0,0 +1,368 @@
+import asyncio
+import json
+import logging
+import math
+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"
+
+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[..., T], *args) -> T:
+ """
+ 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", "avatarmod", "pfpmod"))
+ async def avatar_modify(self, ctx: commands.Context) -> None:
+ """Groups all of the pfp modifying commands to allow a single concurrency limit."""
+ if not ctx.invoked_subcommand:
+ await invoke_help_command(ctx)
+
+ @avatar_modify.command(name="8bitify", root_aliases=("8bitify",))
+ async def eightbit_command(self, ctx: commands.Context) -> None:
+ """Pixelates your avatar and changes the palette to an 8bit one."""
+ async with ctx.typing():
+ 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(
+ aliases=("savatar", "spookify"),
+ root_aliases=("spookyavatar", "spookify", "savatar"),
+ brief="Spookify an user's avatar."
+ )
+ async def spookyavatar(self, ctx: commands.Context, member: discord.Member = None) -> None:
+ """This "spookifies" the given user's avatar, with a random *spooky* effect."""
+ if member is None:
+ member = ctx.author
+
+ 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)
+
+ @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():
+ 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 not 1 <= squares <= MAX_SQUARES:
+ raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.")
+
+ sqrt = math.sqrt(squares)
+
+ if not sqrt.is_integer():
+ squares = math.ceil(sqrt) ** 2 # Get the next perfect square
+
+ file_name = file_safe_name("mosaic_avatar", ctx.author.display_name)
+
+ img_bytes = await member.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,
+ 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)
+
+ await ctx.send(file=file, embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the AvatarModify cog."""
+ bot.add_cog(AvatarModify(bot))
diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py
new file mode 100644
index 00000000..a175602f
--- /dev/null
+++ b/bot/exts/evergreen/catify.py
@@ -0,0 +1,88 @@
+import random
+from contextlib import suppress
+from typing import Optional
+
+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):
+ """Cog for the catify command."""
+
+ def __init__(self, bot: commands.Bot):
+ 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.
+
+ If no text is given then the users nickname is edited.
+ """
+ if not text:
+ display_name = ctx.author.display_name
+
+ if len(display_name) > 26:
+ embed = Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=(
+ "Your display name is too long to be catified! "
+ "Please change it to be under 26 characters."
+ ),
+ color=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+ return
+
+ else:
+ display_name += f" | {random.choice(Cats.cats)}"
+
+ await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none())
+
+ with suppress(Forbidden):
+ await ctx.author.edit(nick=display_name)
+ else:
+ if len(text) >= 1500:
+ embed = Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="Submitted text was too large! Please submit something under 1500 characters.",
+ color=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+ return
+
+ string_list = text.split()
+ for index, name in enumerate(string_list):
+ name = name.lower()
+ if "cat" in name:
+ if random.randint(0, 5) == 5:
+ string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**")
+ else:
+ string_list[index] = name.replace("cat", random.choice(Cats.cats))
+ for element in Cats.cats:
+ if element in name:
+ string_list[index] = name.replace(element, "cat")
+
+ string_len = len(string_list) // 3 or len(string_list)
+
+ for _ in range(random.randint(1, string_len)):
+ # insert cat at random index
+ if random.randint(0, 5) == 5:
+ string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**")
+ else:
+ string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats))
+
+ text = helpers.suppress_links(" ".join(string_list))
+ await ctx.send(
+ f">>> {text}",
+ allowed_mentions=AllowedMentions.none()
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Loads the catify cog."""
+ bot.add_cog(Catify(bot))
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('> ')}"
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')