aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/evergreen
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/evergreen')
-rw-r--r--bot/exts/evergreen/avatar_modification/__init__.py0
-rw-r--r--bot/exts/evergreen/avatar_modification/_effects.py296
-rw-r--r--bot/exts/evergreen/avatar_modification/avatar_modify.py372
3 files changed, 0 insertions, 668 deletions
diff --git a/bot/exts/evergreen/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/bot/exts/evergreen/avatar_modification/__init__.py
+++ /dev/null
diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py
deleted file mode 100644
index df741973..00000000
--- a/bot/exts/evergreen/avatar_modification/_effects.py
+++ /dev/null
@@ -1,296 +0,0 @@
-import math
-import random
-from io import BytesIO
-from pathlib import Path
-from typing import Callable, Optional
-
-import discord
-from PIL import Image, ImageDraw, ImageOps
-
-from bot.constants import Colours
-
-
-class PfpEffects:
- """
- Implements various image modifying effects, for the PfpModify cog.
-
- All of these functions are slow, and blocking, so they should be ran in executors.
- """
-
- @staticmethod
- def apply_effect(image_bytes: bytes, effect: 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 = im.resize((1024, 1024))
- im = effect(im, *args)
-
- bufferedio = BytesIO()
- im.save(bufferedio, format="PNG")
- bufferedio.seek(0)
-
- return discord.File(bufferedio, filename=filename)
-
- @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]) -> 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 = 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 flip_effect(image: Image.Image) -> Image.Image:
- """
- Flips the image horizontally.
-
- This is done by just using ImageOps.mirror().
- """
- image = ImageOps.mirror(image)
-
- return image
-
- @staticmethod
- def easterify_effect(image: Image.Image, overlay_image: Optional[Image.Image] = None) -> Image.Image:
- """
- Applies the easter effect to the given image.
-
- This is done by getting the closest "easter" colour to each pixel and changing the colour
- to the half-way RGB value.
-
- We also then add an overlay image on top in middle right, a chocolate bunny by default.
- """
- if overlay_image:
- ratio = 64 / overlay_image.height
- overlay_image = overlay_image.resize((
- round(overlay_image.width * ratio),
- round(overlay_image.height * ratio)
- ))
- overlay_image = overlay_image.convert("RGBA")
- else:
- overlay_image = Image.open(Path("bot/resources/easter/chocolate_bunny.png"))
-
- alpha = image.getchannel("A").getdata()
- image = image.convert("RGB")
- image = ImageOps.posterize(image, 6)
-
- data = image.getdata()
- data_set = set(data)
- easterified_data_set = {}
-
- for x in data_set:
- easterified_data_set[x] = PfpEffects.closest(x)
- new_pixel_data = [
- (*easterified_data_set[x], alpha[i])
- if x in easterified_data_set else x
- for i, x in enumerate(data)
- ]
-
- im = Image.new("RGBA", image.size)
- im.putdata(new_pixel_data)
- im.alpha_composite(
- overlay_image,
- (im.width - overlay_image.width, (im.height - overlay_image.height) // 2)
- )
- return im
-
- @staticmethod
- def split_image(img: Image.Image, squares: int) -> list:
- """
- Split an image into a selection of squares, specified by the squares argument.
-
- Explanation:
-
- 1. It gets the width and the height of the Image passed to the function.
-
- 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say
- 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed
- to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it
- as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows
- and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the
- program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed.
-
- 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares,
- the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares)
- passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note:
- x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width
- in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width.
- x_frac and y_frac are width and height of a single square (split piece).
-
- 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial
- square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding
- value to right and bottom, it's creating the initial square (split piece).
-
- 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is
- True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between
- them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list
- where it stores them. The program keeps repeating this process till all 25 squares get added to the list.
-
- 6. It returns new_imgs, a list of squares (split pieces).
- """
- width, heigth = img.size
-
- xy = math.sqrt(squares)
-
- x_frac = width // xy
- y_frac = heigth // xy
-
- left, top, right, bottom, = 0, 0, x_frac, y_frac
-
- new_imgs = []
-
- for index in range(squares):
- new_img = img.crop((left, top, right, bottom))
- new_imgs.append(new_img)
-
- if (index + 1) % xy == 0:
- top += y_frac
- bottom += y_frac
- left = 0
- right = x_frac
- else:
- left += x_frac
- right += x_frac
-
- return new_imgs
-
- @staticmethod
- def join_images(images: 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(image: Image.Image, squares: int) -> Image.Image:
- """
- Applies a mosaic effect to the given image.
-
- The "squares" argument specifies the number of squares to split
- the image into. This should be a square number.
- """
- img_squares = PfpEffects.split_image(image, squares)
- new_img = PfpEffects.join_images(img_squares)
-
- return new_img
diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py
deleted file mode 100644
index 18202902..00000000
--- a/bot/exts/evergreen/avatar_modification/avatar_modify.py
+++ /dev/null
@@ -1,372 +0,0 @@
-import asyncio
-import json
-import logging
-import math
-import string
-import unicodedata
-from concurrent.futures import ThreadPoolExecutor
-from pathlib import Path
-from typing import Callable, Optional, TypeVar, Union
-
-import discord
-from discord.ext import commands
-
-from bot.bot import Bot
-from bot.constants import Colours, Emojis
-from bot.exts.evergreen.avatar_modification._effects import PfpEffects
-from bot.utils.extensions import invoke_help_command
-from bot.utils.halloween import spookifications
-
-log = logging.getLogger(__name__)
-
-_EXECUTOR = ThreadPoolExecutor(10)
-
-FILENAME_STRING = "{effect}_{author}.png"
-
-MAX_SQUARES = 10_000
-
-T = TypeVar("T")
-
-GENDER_OPTIONS = json.loads(Path("bot/resources/pride/gender_options.json").read_text("utf8"))
-
-
-async def in_executor(func: Callable[..., T], *args) -> T:
- """
- Runs the given synchronous function `func` in an executor.
-
- This is useful for running slow, blocking code within async
- functions, so that they don't block the bot.
- """
- log.trace(f"Running {func.__name__} in an executor.")
- loop = asyncio.get_event_loop()
- return await loop.run_in_executor(_EXECUTOR, func, *args)
-
-
-def file_safe_name(effect: str, display_name: str) -> str:
- """Returns a file safe filename based on the given effect and display name."""
- valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}"
-
- file_name = FILENAME_STRING.format(effect=effect, author=display_name)
-
- # Replace spaces
- file_name = file_name.replace(" ", "_")
-
- # Normalize unicode characters
- cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode()
-
- # Remove invalid filename characters
- cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars)
- return cleaned_filename
-
-
-class AvatarModify(commands.Cog):
- """Various commands for users to apply affects to their own avatars."""
-
- def __init__(self, bot: Bot):
- self.bot = bot
-
- async def _fetch_user(self, user_id: int) -> Optional[discord.User]:
- """
- Fetches a user and handles errors.
-
- This helper function is required as the member cache doesn't always have the most up to date
- profile picture. This can lead to errors if the image is deleted from the Discord CDN.
- fetch_member can't be used due to the avatar url being part of the user object, and
- some weird caching that D.py does
- """
- try:
- user = await self.bot.fetch_user(user_id)
- except discord.errors.NotFound:
- log.debug(f"User {user_id} could not be found.")
- return None
- except discord.HTTPException:
- log.exception(f"Exception while trying to retrieve user {user_id} from Discord.")
- return None
-
- return user
-
- @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod"))
- async def avatar_modify(self, ctx: commands.Context) -> None:
- """Groups all of the pfp modifying commands to allow a single concurrency limit."""
- if not ctx.invoked_subcommand:
- await invoke_help_command(ctx)
-
- @avatar_modify.command(name="8bitify", root_aliases=("8bitify",))
- async def eightbit_command(self, ctx: commands.Context) -> None:
- """Pixelates your avatar and changes the palette to an 8bit one."""
- async with ctx.typing():
- user = await self._fetch_user(ctx.author.id)
- if not user:
- await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
- return
-
- image_bytes = await user.display_avatar.replace(size=1024).read()
- file_name = file_safe_name("eightbit_avatar", ctx.author.display_name)
-
- file = await in_executor(
- PfpEffects.apply_effect,
- image_bytes,
- PfpEffects.eight_bitify_effect,
- file_name
- )
-
- embed = discord.Embed(
- title="Your 8-bit avatar",
- description="Here is your avatar. I think it looks all cool and 'retro'."
- )
-
- embed.set_image(url=f"attachment://{file_name}")
- embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_avatar.url)
-
- await ctx.send(embed=embed, file=file)
-
- @avatar_modify.command(name="reverse", root_aliases=("reverse",))
- async def reverse(self, ctx: commands.Context, *, text: Optional[str]) -> None:
- """
- Reverses the sent text.
-
- If no text is provided, the user's profile picture will be reversed.
- """
- if text:
- await ctx.send(f"> {text[::-1]}", allowed_mentions=discord.AllowedMentions.none())
- return
-
- async with ctx.typing():
- user = await self._fetch_user(ctx.author.id)
- if not user:
- await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
- return
-
- image_bytes = await user.display_avatar.replace(size=1024).read()
- filename = file_safe_name("reverse_avatar", ctx.author.display_name)
-
- file = await in_executor(
- PfpEffects.apply_effect,
- image_bytes,
- PfpEffects.flip_effect,
- filename
- )
-
- embed = discord.Embed(
- title="Your reversed avatar.",
- description="Here is your reversed avatar. I think it is a spitting image of you."
- )
-
- embed.set_image(url=f"attachment://{filename}")
- embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_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: Union[discord.Colour, str]) -> None:
- """
- This "Easterifies" the user's avatar.
-
- Given colours will produce a personalised egg in the corner, similar to the egg_decorate command.
- If colours are not given, a nice little chocolate bunny will sit in the corner.
- Colours are split by spaces, unless you wrap the colour name in double quotes.
- Discord colour names, HTML colour names, XKCD colour names and hex values are accepted.
- """
- async def send(*args, **kwargs) -> str:
- """
- This replaces the original ctx.send.
-
- When invoking the egg decorating command, the egg itself doesn't print to to the channel.
- Returns the message content so that if any errors occur, the error message can be output.
- """
- if args:
- return args[0]
-
- async with ctx.typing():
- user = await self._fetch_user(ctx.author.id)
- if not user:
- await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
- return
-
- egg = None
- if colours:
- send_message = ctx.send
- ctx.send = send # Assigns ctx.send to a fake send
- egg = await ctx.invoke(self.bot.get_command("eggdecorate"), *colours)
- if isinstance(egg, str): # When an error message occurs in eggdecorate.
- await send_message(egg)
- return
- ctx.send = send_message # Reassigns ctx.send
-
- image_bytes = await user.display_avatar.replace(size=256).read()
- file_name = file_safe_name("easterified_avatar", ctx.author.display_name)
-
- file = await in_executor(
- PfpEffects.apply_effect,
- image_bytes,
- PfpEffects.easterify_effect,
- file_name,
- egg
- )
-
- embed = discord.Embed(
- title="Your Lovely Easterified Avatar!",
- description="Here is your lovely avatar, all bright and colourful\nwith Easter pastel colours. Enjoy :D"
- )
- embed.set_image(url=f"attachment://{file_name}")
- embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=user.display_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(
- title="Your Lovely Pride Avatar!",
- description=f"Here is your lovely avatar, surrounded by\n a beautiful {option} flag. Enjoy :D"
- )
- embed.set_image(url=f"attachment://{file_name}")
- embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.avatar_url)
- await ctx.send(file=file, embed=embed)
-
- @avatar_modify.group(
- aliases=("avatarpride", "pridepfp", "prideprofile"),
- root_aliases=("prideavatar", "avatarpride", "pridepfp", "prideprofile"),
- invoke_without_command=True
- )
- async def prideavatar(self, ctx: commands.Context, option: str = "lgbt", pixels: int = 64) -> None:
- """
- This surrounds an avatar with a border of a specified LGBT flag.
-
- This defaults to the LGBT rainbow flag if none is given.
- The amount of pixels can be given which determines the thickness of the flag border.
- This has a maximum of 512px and defaults to a 64px border.
- The full image is 1024x1024.
- """
- option = option.lower()
- pixels = max(0, min(512, pixels))
- flag = GENDER_OPTIONS.get(option)
- if flag is None:
- await ctx.send("I don't have that flag!")
- return
-
- async with ctx.typing():
- user = await self._fetch_user(ctx.author.id)
- if not user:
- await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
- return
- image_bytes = await user.display_avatar.replace(size=1024).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(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) -> None:
- """This "spookifies" the user's avatar, with a random *spooky* effect."""
- user = await self._fetch_user(ctx.author.id)
- if not user:
- await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
- return
-
- async with ctx.typing():
- image_bytes = await user.display_avatar.replace(size=1024).read()
-
- file_name = file_safe_name("spooky_avatar", 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_image(url=f"attachment://{file_name}")
- embed.set_footer(text=f"Made by {ctx.author.display_name}.", icon_url=ctx.author.display_avatar.url)
-
- await ctx.send(file=file, embed=embed)
-
- @avatar_modify.command(name="mosaic", root_aliases=("mosaic",))
- async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None:
- """Splits your avatar into x squares, randomizes them and stitches them back into a new image!"""
- async with ctx.typing():
- user = await self._fetch_user(ctx.author.id)
- if not user:
- await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
- return
-
- if not 1 <= squares <= MAX_SQUARES:
- raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.")
-
- sqrt = math.sqrt(squares)
-
- if not sqrt.is_integer():
- squares = math.ceil(sqrt) ** 2 # Get the next perfect square
-
- file_name = file_safe_name("mosaic_avatar", ctx.author.display_name)
-
- img_bytes = await user.display_avatar.replace(size=1024).read()
-
- file = await in_executor(
- PfpEffects.apply_effect,
- img_bytes,
- PfpEffects.mosaic_effect,
- file_name,
- squares,
- )
-
- if squares == 1:
- title = "Hooh... that was a lot of work"
- description = "I present to you... Yourself!"
- elif squares == MAX_SQUARES:
- title = "Testing the limits I see..."
- description = "What a masterpiece. :star:"
- else:
- title = "Your mosaic avatar"
- description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares."
-
- embed = discord.Embed(
- title=title,
- description=description,
- colour=Colours.blue
- )
-
- embed.set_image(url=f"attachment://{file_name}")
- embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.display_avatar.url)
-
- await ctx.send(file=file, embed=embed)
-
-
-def setup(bot: Bot) -> None:
- """Load the AvatarModify cog."""
- bot.add_cog(AvatarModify(bot))