diff options
Diffstat (limited to '')
| -rw-r--r-- | Pipfile | 2 | ||||
| -rw-r--r-- | Pipfile.lock | 60 | ||||
| -rw-r--r-- | bot/__init__.py | 13 | ||||
| -rw-r--r-- | bot/bot.py | 41 | ||||
| -rw-r--r-- | bot/command.py | 18 | ||||
| -rw-r--r-- | bot/constants.py | 17 | ||||
| -rw-r--r-- | bot/exts/easter/avatar_easterifier.py | 128 | ||||
| -rw-r--r-- | bot/exts/evergreen/8bitify.py | 55 | ||||
| -rw-r--r-- | bot/exts/evergreen/help.py | 4 | ||||
| -rw-r--r-- | bot/exts/evergreen/profile_pic_modification/__init__.py | 0 | ||||
| -rw-r--r-- | bot/exts/evergreen/profile_pic_modification/_effects.py | 139 | ||||
| -rw-r--r-- | bot/exts/evergreen/profile_pic_modification/pfp_modify.py | 307 | ||||
| -rw-r--r-- | bot/exts/halloween/spookyavatar.py | 52 | ||||
| -rw-r--r-- | bot/exts/pride/pride_avatar.py | 177 | ||||
| -rw-r--r-- | bot/group.py | 18 | ||||
| -rw-r--r-- | bot/resources/pride/gender_options.json | 41 | 
16 files changed, 612 insertions, 460 deletions
@@ -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) @@ -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" +}  |