aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock60
-rw-r--r--bot/__init__.py13
-rw-r--r--bot/bot.py41
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py17
-rw-r--r--bot/exts/easter/avatar_easterifier.py128
-rw-r--r--bot/exts/evergreen/8bitify.py55
-rw-r--r--bot/exts/evergreen/help.py4
-rw-r--r--bot/exts/evergreen/profile_pic_modification/__init__.py0
-rw-r--r--bot/exts/evergreen/profile_pic_modification/_effects.py139
-rw-r--r--bot/exts/evergreen/profile_pic_modification/pfp_modify.py307
-rw-r--r--bot/exts/halloween/spookyavatar.py52
-rw-r--r--bot/exts/pride/pride_avatar.py177
-rw-r--r--bot/group.py18
-rw-r--r--bot/resources/pride/gender_options.json41
16 files changed, 612 insertions, 460 deletions
diff --git a/Pipfile b/Pipfile
index f20f6845..4f9b2237 100644
--- a/Pipfile
+++ b/Pipfile
@@ -8,13 +8,13 @@ aiodns = "~=2.0"
arrow = "~=0.14"
beautifulsoup4 = "~=4.8"
fuzzywuzzy = "~=0.17"
-pillow = "~=8.1"
pytz = "~=2019.2"
sentry-sdk = "~=0.19"
PyYAML = "~=5.4"
"discord.py" = {extras = ["voice"], version = "~=1.5.1"}
async-rediscache = {extras = ["fakeredis"], version = "~=0.1.4"}
emojis = "~=0.6.0"
+pillow-simd = "~=7.0"
matplotlib = "~=3.4.1"
[dev-packages]
diff --git a/Pipfile.lock b/Pipfile.lock
index d7fc6b27..06033465 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -1,7 +1,7 @@
{
"_meta": {
"hash": {
- "sha256": "03b52d5b9fdfa6d037780d5aa2896c82fd5454a40bd69acf7e9b0e129557dbd5"
+ "sha256": "3ef6251d323e7b19a7810ad060fb74aa830e6f52f77a50a03a18e78fdeb29fd9"
},
"pipfile-spec": 6,
"requires": {
@@ -332,6 +332,13 @@
"markers": "python_version >= '3.5'",
"version": "==4.7.6"
},
+ "pillow-simd": {
+ "hashes": [
+ "sha256:c27907af0e7ede1ceed281719e722e7dbf3e1dbfe561373978654a6b64896cb7"
+ ],
+ "index": "pypi",
+ "version": "==7.0.0.post3"
+ },
"numpy": {
"hashes": [
"sha256:2428b109306075d89d21135bdd6b785f132a1f5a3260c371cee1fae427e12727",
@@ -362,45 +369,6 @@
"markers": "python_version >= '3.7'",
"version": "==1.20.2"
},
- "pillow": {
- "hashes": [
- "sha256:01425106e4e8cee195a411f729cff2a7d61813b0b11737c12bd5991f5f14bcd5",
- "sha256:031a6c88c77d08aab84fecc05c3cde8414cd6f8406f4d2b16fed1e97634cc8a4",
- "sha256:083781abd261bdabf090ad07bb69f8f5599943ddb539d64497ed021b2a67e5a9",
- "sha256:0d19d70ee7c2ba97631bae1e7d4725cdb2ecf238178096e8c82ee481e189168a",
- "sha256:0e04d61f0064b545b989126197930807c86bcbd4534d39168f4aa5fda39bb8f9",
- "sha256:12e5e7471f9b637762453da74e390e56cc43e486a88289995c1f4c1dc0bfe727",
- "sha256:22fd0f42ad15dfdde6c581347eaa4adb9a6fc4b865f90b23378aa7914895e120",
- "sha256:238c197fc275b475e87c1453b05b467d2d02c2915fdfdd4af126145ff2e4610c",
- "sha256:3b570f84a6161cf8865c4e08adf629441f56e32f180f7aa4ccbd2e0a5a02cba2",
- "sha256:463822e2f0d81459e113372a168f2ff59723e78528f91f0bd25680ac185cf797",
- "sha256:4d98abdd6b1e3bf1a1cbb14c3895226816e666749ac040c4e2554231068c639b",
- "sha256:5afe6b237a0b81bd54b53f835a153770802f164c5570bab5e005aad693dab87f",
- "sha256:5b70110acb39f3aff6b74cf09bb4169b167e2660dabc304c1e25b6555fa781ef",
- "sha256:5cbf3e3b1014dddc45496e8cf38b9f099c95a326275885199f427825c6522232",
- "sha256:624b977355cde8b065f6d51b98497d6cd5fbdd4f36405f7a8790e3376125e2bb",
- "sha256:63728564c1410d99e6d1ae8e3b810fe012bc440952168af0a2877e8ff5ab96b9",
- "sha256:66cc56579fd91f517290ab02c51e3a80f581aba45fd924fcdee01fa06e635812",
- "sha256:6c32cc3145928c4305d142ebec682419a6c0a8ce9e33db900027ddca1ec39178",
- "sha256:8bb1e155a74e1bfbacd84555ea62fa21c58e0b4e7e6b20e4447b8d07990ac78b",
- "sha256:95d5ef984eff897850f3a83883363da64aae1000e79cb3c321915468e8c6add5",
- "sha256:a013cbe25d20c2e0c4e85a9daf438f85121a4d0344ddc76e33fd7e3965d9af4b",
- "sha256:a787ab10d7bb5494e5f76536ac460741788f1fbce851068d73a87ca7c35fc3e1",
- "sha256:a7d5e9fad90eff8f6f6106d3b98b553a88b6f976e51fce287192a5d2d5363713",
- "sha256:aac00e4bc94d1b7813fe882c28990c1bc2f9d0e1aa765a5f2b516e8a6a16a9e4",
- "sha256:b91c36492a4bbb1ee855b7d16fe51379e5f96b85692dc8210831fbb24c43e484",
- "sha256:c03c07ed32c5324939b19e36ae5f75c660c81461e312a41aea30acdd46f93a7c",
- "sha256:c5236606e8570542ed424849f7852a0ff0bce2c4c8d0ba05cc202a5a9c97dee9",
- "sha256:c6b39294464b03457f9064e98c124e09008b35a62e3189d3513e5148611c9388",
- "sha256:cb7a09e173903541fa888ba010c345893cd9fc1b5891aaf060f6ca77b6a3722d",
- "sha256:d68cb92c408261f806b15923834203f024110a2e2872ecb0bd2a110f89d3c602",
- "sha256:dc38f57d8f20f06dd7c3161c59ca2c86893632623f33a42d592f097b00f720a9",
- "sha256:e98eca29a05913e82177b3ba3d198b1728e164869c613d76d0de4bde6768a50e",
- "sha256:f217c3954ce5fd88303fc0c317af55d5e0204106d86dea17eb8205700d47dec2"
- ],
- "index": "pypi",
- "version": "==8.2.0"
- },
"pycares": {
"hashes": [
"sha256:050f00b39ed77ea8a4e555f09417d4b1a6b5baa24bb9531a3e15d003d2319b3f",
@@ -709,11 +677,11 @@
},
"identify": {
"hashes": [
- "sha256:43cb1965e84cdd247e875dec6d13332ef5be355ddc16776396d98089b9053d87",
- "sha256:c7c0f590526008911ccc5ceee6ed7b085cbc92f7b6591d0ee5913a130ad64034"
+ "sha256:398cb92a7599da0b433c65301a1b62b9b1f4bb8248719b84736af6c0b22289d6",
+ "sha256:4537474817e0bbb8cea3e5b7504b7de6d44e3f169a90846cbc6adb0fc8294502"
],
"markers": "python_full_version >= '3.6.1'",
- "version": "==2.2.2"
+ "version": "==2.2.3"
},
"mccabe": {
"hashes": [
@@ -724,10 +692,10 @@
},
"nodeenv": {
"hashes": [
- "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9",
- "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c"
+ "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b",
+ "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"
],
- "version": "==1.5.0"
+ "version": "==1.6.0"
},
"pep8-naming": {
"hashes": [
diff --git a/bot/__init__.py b/bot/__init__.py
index bdb18666..d0992912 100644
--- a/bot/__init__.py
+++ b/bot/__init__.py
@@ -2,11 +2,15 @@ import asyncio
import logging
import logging.handlers
import os
+from functools import partial, partialmethod
from pathlib import Path
import arrow
+from discord.ext import commands
+from bot.command import Command
from bot.constants import Client
+from bot.group import Group
# Configure the "TRACE" logging level (e.g. "log.trace(message)")
@@ -70,3 +74,12 @@ logging.getLogger().info('Logging initialization complete')
# On Windows, the selector event loop is required for aiodns.
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+
+# Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases.
+# Must be patched before any cogs are added.
+commands.command = partial(commands.command, cls=Command)
+commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
+
+commands.group = partial(commands.group, cls=Group)
+commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=Group)
diff --git a/bot/bot.py b/bot/bot.py
index e9750697..7e495940 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):
@@ -139,6 +159,27 @@ class Bot(commands.Bot):
"""
await self._guild_available.wait()
+ def _add_root_aliases(self, command: commands.Command) -> None:
+ """Recursively add root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._add_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ if alias in self.all_commands:
+ raise commands.CommandRegistrationError(alias, alias_conflict=True)
+
+ self.all_commands[alias] = command
+
+ def _remove_root_aliases(self, command: commands.Command) -> None:
+ """Recursively remove root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._remove_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ self.all_commands.pop(alias, None)
+
_allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
diff --git a/bot/command.py b/bot/command.py
new file mode 100644
index 00000000..0fb900f7
--- /dev/null
+++ b/bot/command.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index a64882db..f390d8ce 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -147,8 +147,25 @@ class Colours:
python_yellow = 0xFFD43B
grass_green = 0x66ff00
+ easter_like_colours = [
+ (255, 247, 0),
+ (255, 255, 224),
+ (0, 255, 127),
+ (189, 252, 201),
+ (255, 192, 203),
+ (255, 160, 122),
+ (181, 115, 220),
+ (221, 160, 221),
+ (200, 162, 200),
+ (238, 130, 238),
+ (135, 206, 235),
+ (0, 204, 204),
+ (64, 224, 208),
+ ]
+
class Emojis:
+ cross_mark = "\u274C"
star = "\u2B50"
christmas_tree = "\U0001F384"
check = "\u2611"
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 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/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/profile_pic_modification/__init__.py b/bot/exts/evergreen/profile_pic_modification/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/evergreen/profile_pic_modification/__init__.py
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..e415d700
--- /dev/null
+++ b/bot/exts/evergreen/profile_pic_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/profile_pic_modification/pfp_modify.py b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py
new file mode 100644
index 00000000..51999fb0
--- /dev/null
+++ b/bot/exts/evergreen/profile_pic_modification/pfp_modify.py
@@ -0,0 +1,307 @@
+import asyncio
+import json
+import logging
+import typing as t
+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)
+
+
+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 = FILENAME_STRING.format(
+ effect="eightbit_avatar",
+ author=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 = FILENAME_STRING.format(
+ effect="easterified_avatar",
+ author=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 = 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
+ )
+
+ 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 = FILENAME_STRING.format(
+ effect="spooky_avatar",
+ author=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))
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))
diff --git a/bot/group.py b/bot/group.py
new file mode 100644
index 00000000..a7bc59b7
--- /dev/null
+++ b/bot/group.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Group(commands.Group):
+ """
+ A `discord.ext.commands.Group` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level groups rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a group must be a list or a tuple of strings.")
diff --git a/bot/resources/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"
+}