diff options
Diffstat (limited to 'bot/exts/fun/fun.py')
| -rw-r--r-- | bot/exts/fun/fun.py | 250 | 
1 files changed, 250 insertions, 0 deletions
| diff --git a/bot/exts/fun/fun.py b/bot/exts/fun/fun.py new file mode 100644 index 00000000..b148f1f3 --- /dev/null +++ b/bot/exts/fun/fun.py @@ -0,0 +1,250 @@ +import functools +import json +import logging +import random +from collections.abc import Iterable +from pathlib import Path +from typing import Callable, Optional, Union + +from discord import Embed, Message +from discord.ext import commands +from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content + +from bot import utils +from bot.bot import Bot +from bot.constants import Client, Colours, Emojis +from bot.utils import helpers + +log = logging.getLogger(__name__) + +UWU_WORDS = { +    "fi": "fwi", +    "l": "w", +    "r": "w", +    "some": "sum", +    "th": "d", +    "thing": "fing", +    "tho": "fo", +    "you're": "yuw'we", +    "your": "yur", +    "you": "yuw", +} + + +def caesar_cipher(text: str, offset: int) -> Iterable[str]: +    """ +    Implements a lazy Caesar Cipher algorithm. + +    Encrypts a `text` given a specific integer `offset`. The sign +    of the `offset` dictates the direction in which it shifts to, +    with a negative value shifting to the left, and a positive +    value shifting to the right. +    """ +    for char in text: +        if not char.isascii() or not char.isalpha() or char.isspace(): +            yield char +            continue + +        case_start = 65 if char.isupper() else 97 +        true_offset = (ord(char) - case_start + offset) % 26 + +        yield chr(case_start + true_offset) + + +class Fun(Cog): +    """A collection of general commands for fun.""" + +    def __init__(self, bot: Bot): +        self.bot = bot + +        self._caesar_cipher_embed = json.loads(Path("bot/resources/fun/caesar_info.json").read_text("UTF-8")) + +    @staticmethod +    def _get_random_die() -> str: +        """Generate a random die emoji, ready to be sent on Discord.""" +        die_name = f"dice_{random.randint(1, 6)}" +        return getattr(Emojis, die_name) + +    @commands.command() +    async def roll(self, ctx: Context, num_rolls: int = 1) -> None: +        """Outputs a number of random dice emotes (up to 6).""" +        if 1 <= num_rolls <= 6: +            dice = " ".join(self._get_random_die() for _ in range(num_rolls)) +            await ctx.send(dice) +        else: +            raise BadArgument(f"`{Client.prefix}roll` only supports between 1 and 6 rolls.") + +    @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) +    async def uwu_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: +        """Converts a given `text` into it's uwu equivalent.""" +        conversion_func = functools.partial( +            utils.replace_many, replacements=UWU_WORDS, ignore_case=True, match_case=True +        ) +        text, embed = await Fun._get_text_and_embed(ctx, text) +        # Convert embed if it exists +        if embed is not None: +            embed = Fun._convert_embed(conversion_func, embed) +        converted_text = conversion_func(text) +        converted_text = helpers.suppress_links(converted_text) +        # Don't put >>> if only embed present +        if converted_text: +            converted_text = f">>> {converted_text.lstrip('> ')}" +        await ctx.send(content=converted_text, embed=embed) + +    @commands.command(name="randomcase", aliases=("rcase", "randomcaps", "rcaps",)) +    async def randomcase_command(self, ctx: Context, *, text: clean_content(fix_channel_mentions=True)) -> None: +        """Randomly converts the casing of a given `text`.""" +        def conversion_func(text: str) -> str: +            """Randomly converts the casing of a given string.""" +            return "".join( +                char.upper() if round(random.random()) else char.lower() for char in text +            ) +        text, embed = await Fun._get_text_and_embed(ctx, text) +        # Convert embed if it exists +        if embed is not None: +            embed = Fun._convert_embed(conversion_func, embed) +        converted_text = conversion_func(text) +        converted_text = helpers.suppress_links(converted_text) +        # Don't put >>> if only embed present +        if converted_text: +            converted_text = f">>> {converted_text.lstrip('> ')}" +        await ctx.send(content=converted_text, embed=embed) + +    @commands.group(name="caesarcipher", aliases=("caesar", "cc",)) +    async def caesarcipher_group(self, ctx: Context) -> None: +        """ +        Translates a message using the Caesar Cipher. + +        See `decrypt`, `encrypt`, and `info` subcommands. +        """ +        if ctx.invoked_subcommand is None: +            await ctx.invoke(self.bot.get_command("help"), "caesarcipher") + +    @caesarcipher_group.command(name="info") +    async def caesarcipher_info(self, ctx: Context) -> None: +        """Information about the Caesar Cipher.""" +        embed = Embed.from_dict(self._caesar_cipher_embed) +        embed.colour = Colours.dark_green + +        await ctx.send(embed=embed) + +    @staticmethod +    async def _caesar_cipher(ctx: Context, offset: int, msg: str, left_shift: bool = False) -> None: +        """ +        Given a positive integer `offset`, translates and sends the given `msg`. + +        Performs a right shift by default unless `left_shift` is specified as `True`. + +        Also accepts a valid Discord Message ID or link. +        """ +        if offset < 0: +            await ctx.send(":no_entry: Cannot use a negative offset.") +            return + +        if left_shift: +            offset = -offset + +        def conversion_func(text: str) -> str: +            """Encrypts the given string using the Caesar Cipher.""" +            return "".join(caesar_cipher(text, offset)) + +        text, embed = await Fun._get_text_and_embed(ctx, msg) + +        if embed is not None: +            embed = Fun._convert_embed(conversion_func, embed) + +        converted_text = conversion_func(text) + +        if converted_text: +            converted_text = f">>> {converted_text.lstrip('> ')}" + +        await ctx.send(content=converted_text, embed=embed) + +    @caesarcipher_group.command(name="encrypt", aliases=("rightshift", "rshift", "enc",)) +    async def caesarcipher_encrypt(self, ctx: Context, offset: int, *, msg: str) -> None: +        """ +        Given a positive integer `offset`, encrypt the given `msg`. + +        Performs a right shift of the letters in the message. + +        Also accepts a valid Discord Message ID or link. +        """ +        await self._caesar_cipher(ctx, offset, msg, left_shift=False) + +    @caesarcipher_group.command(name="decrypt", aliases=("leftshift", "lshift", "dec",)) +    async def caesarcipher_decrypt(self, ctx: Context, offset: int, *, msg: str) -> None: +        """ +        Given a positive integer `offset`, decrypt the given `msg`. + +        Performs a left shift of the letters in the message. + +        Also accepts a valid Discord Message ID or link. +        """ +        await self._caesar_cipher(ctx, offset, msg, left_shift=True) + +    @staticmethod +    async def _get_text_and_embed(ctx: Context, text: str) -> tuple[str, Optional[Embed]]: +        """ +        Attempts to extract the text and embed from a possible link to a discord Message. + +        Does not retrieve the text and embed from the Message if it is in a channel the user does +        not have read permissions in. + +        Returns a tuple of: +            str: If `text` is a valid discord Message, the contents of the message, else `text`. +            Optional[Embed]: The embed if found in the valid Message, else None +        """ +        embed = None + +        msg = await Fun._get_discord_message(ctx, text) +        # Ensure the user has read permissions for the channel the message is in +        if isinstance(msg, Message): +            permissions = msg.channel.permissions_for(ctx.author) +            if permissions.read_messages: +                text = msg.clean_content +                # Take first embed because we can't send multiple embeds +                if msg.embeds: +                    embed = msg.embeds[0] + +        return (text, embed) + +    @staticmethod +    async def _get_discord_message(ctx: Context, text: str) -> Union[Message, str]: +        """ +        Attempts to convert a given `text` to a discord Message object and return it. + +        Conversion will succeed if given a discord Message ID or link. +        Returns `text` if the conversion fails. +        """ +        try: +            text = await MessageConverter().convert(ctx, text) +        except commands.BadArgument: +            log.debug(f"Input '{text:.20}...' is not a valid Discord Message") +        return text + +    @staticmethod +    def _convert_embed(func: Callable[[str, ], str], embed: Embed) -> Embed: +        """ +        Converts the text in an embed using a given conversion function, then return the embed. + +        Only modifies the following fields: title, description, footer, fields +        """ +        embed_dict = embed.to_dict() + +        embed_dict["title"] = func(embed_dict.get("title", "")) +        embed_dict["description"] = func(embed_dict.get("description", "")) + +        if "footer" in embed_dict: +            embed_dict["footer"]["text"] = func(embed_dict["footer"].get("text", "")) + +        if "fields" in embed_dict: +            for field in embed_dict["fields"]: +                field["name"] = func(field.get("name", "")) +                field["value"] = func(field.get("value", "")) + +        return Embed.from_dict(embed_dict) + + +def setup(bot: Bot) -> None: +    """Load the Fun cog.""" +    bot.add_cog(Fun(bot)) | 
