diff options
Diffstat (limited to 'bot/exts/fun')
| -rw-r--r-- | bot/exts/fun/battleship.py | 4 | ||||
| -rw-r--r-- | bot/exts/fun/connect_four.py | 5 | ||||
| -rw-r--r-- | bot/exts/fun/fun.py | 116 | ||||
| -rw-r--r-- | bot/exts/fun/latex.py | 138 | ||||
| -rw-r--r-- | bot/exts/fun/uwu.py | 204 |
5 files changed, 362 insertions, 105 deletions
diff --git a/bot/exts/fun/battleship.py b/bot/exts/fun/battleship.py index beff196f..77e38427 100644 --- a/bot/exts/fun/battleship.py +++ b/bot/exts/fun/battleship.py @@ -110,8 +110,8 @@ class Game: self.gameover: bool = False - self.turn: Optional[discord.Member] = None - self.next: Optional[discord.Member] = None + self.turn: Optional[Player] = None + self.next: Optional[Player] = None self.match: Optional[re.Match] = None self.surrender: bool = False diff --git a/bot/exts/fun/connect_four.py b/bot/exts/fun/connect_four.py index f53695d5..1b88d065 100644 --- a/bot/exts/fun/connect_four.py +++ b/bot/exts/fun/connect_four.py @@ -5,6 +5,7 @@ from typing import Optional, Union import discord import emojis +from discord import ClientUser, Member from discord.ext import commands from bot.bot import Bot @@ -71,7 +72,9 @@ class Game: await self.message.add_reaction(CROSS_EMOJI) await self.message.edit(content=None, embed=embed) - async def game_over(self, action: str, player1: discord.user, player2: discord.user) -> None: + async def game_over( + self, action: str, player1: Union[ClientUser, Member], player2: Union[ClientUser, Member] + ) -> None: """Announces to public chat.""" if action == "win": await self.channel.send(f"Game Over! {player1.mention} won against {player2.mention}") diff --git a/bot/exts/fun/fun.py b/bot/exts/fun/fun.py index b148f1f3..e7337cb6 100644 --- a/bot/exts/fun/fun.py +++ b/bot/exts/fun/fun.py @@ -1,35 +1,21 @@ -import functools import json import logging import random from collections.abc import Iterable from pathlib import Path -from typing import Callable, Optional, Union +from typing import Literal -from discord import Embed, Message +import pyjokes +from discord import Embed from discord.ext import commands -from discord.ext.commands import BadArgument, Cog, Context, MessageConverter, clean_content +from discord.ext.commands import BadArgument, Cog, Context, clean_content -from bot import utils from bot.bot import Bot from bot.constants import Client, Colours, Emojis -from bot.utils import helpers +from bot.utils import helpers, messages 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]: """ @@ -56,7 +42,6 @@ class Fun(Cog): 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 @@ -74,23 +59,6 @@ class Fun(Cog): 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`.""" @@ -99,10 +67,10 @@ class Fun(Cog): 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) + text, embed = await messages.get_text_and_embed(ctx, text) # Convert embed if it exists if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) + embed = messages.convert_embed(conversion_func, embed) converted_text = conversion_func(text) converted_text = helpers.suppress_links(converted_text) # Don't put >>> if only embed present @@ -148,10 +116,10 @@ class Fun(Cog): """Encrypts the given string using the Caesar Cipher.""" return "".join(caesar_cipher(text, offset)) - text, embed = await Fun._get_text_and_embed(ctx, msg) + text, embed = await messages.get_text_and_embed(ctx, msg) if embed is not None: - embed = Fun._convert_embed(conversion_func, embed) + embed = messages.convert_embed(conversion_func, embed) converted_text = conversion_func(text) @@ -182,67 +150,11 @@ class Fun(Cog): """ 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) + @commands.command() + async def joke(self, ctx: commands.Context, category: Literal["neutral", "chuck", "all"] = "all") -> None: + """Retrieves a joke of the specified `category` from the pyjokes api.""" + joke = pyjokes.get_joke(category=category) + await ctx.send(joke) def setup(bot: Bot) -> None: diff --git a/bot/exts/fun/latex.py b/bot/exts/fun/latex.py new file mode 100644 index 00000000..aeabcd20 --- /dev/null +++ b/bot/exts/fun/latex.py @@ -0,0 +1,138 @@ +import hashlib +import re +import string +from io import BytesIO +from pathlib import Path +from typing import BinaryIO, Optional + +import discord +from PIL import Image +from discord.ext import commands + +from bot.bot import Bot +from bot.constants import Channels, WHITELISTED_CHANNELS +from bot.utils.decorators import whitelist_override + +FORMATTED_CODE_REGEX = re.compile( + r"(?P<delim>(?P<block>```)|``?)" # code delimiter: 1-3 backticks; (?P=block) only matches if it's a block + r"(?(block)(?:(?P<lang>[a-z]+)\n)?)" # if we're in a block, match optional language (only letters plus newline) + r"(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code + r"(?P<code>.*?)" # extract all code inside the markup + r"\s*" # any more whitespace before the end of the code markup + r"(?P=delim)", # match the exact same delimiter from the start again + re.DOTALL | re.IGNORECASE, # "." also matches newlines, case insensitive +) + +LATEX_API_URL = "https://rtex.probablyaweb.site/api/v2" +PASTEBIN_URL = "https://paste.pythondiscord.com" + +THIS_DIR = Path(__file__).parent +CACHE_DIRECTORY = THIS_DIR / "_latex_cache" +CACHE_DIRECTORY.mkdir(exist_ok=True) +TEMPLATE = string.Template(Path("bot/resources/fun/latex_template.txt").read_text()) + +PAD = 10 + +LATEX_ALLOWED_CHANNNELS = WHITELISTED_CHANNELS + ( + Channels.data_science_and_ai, + Channels.algos_and_data_structs, +) + + +def _prepare_input(text: str) -> str: + """Extract latex from a codeblock, if it is in one.""" + if match := FORMATTED_CODE_REGEX.match(text): + return match.group("code") + else: + return text + + +def _process_image(data: bytes, out_file: BinaryIO) -> None: + """Read `data` as an image file, and paste it on a white background.""" + image = Image.open(BytesIO(data)).convert("RGBA") + width, height = image.size + background = Image.new("RGBA", (width + 2 * PAD, height + 2 * PAD), "WHITE") + + # paste the image on the background, using the same image as the mask + # when an RGBA image is passed as the mask, its alpha band is used. + # this has the effect of skipping pasting the pixels where the image is transparent. + background.paste(image, (PAD, PAD), image) + background.save(out_file) + + +class InvalidLatexError(Exception): + """Represents an error caused by invalid latex.""" + + def __init__(self, logs: Optional[str]): + super().__init__(logs) + self.logs = logs + + +class Latex(commands.Cog): + """Renders latex.""" + + def __init__(self, bot: Bot): + self.bot = bot + + async def _generate_image(self, query: str, out_file: BinaryIO) -> None: + """Make an API request and save the generated image to cache.""" + payload = {"code": query, "format": "png"} + async with self.bot.http_session.post(LATEX_API_URL, data=payload, raise_for_status=True) as response: + response_json = await response.json() + if response_json["status"] != "success": + raise InvalidLatexError(logs=response_json.get("log")) + async with self.bot.http_session.get( + f"{LATEX_API_URL}/{response_json['filename']}", + raise_for_status=True + ) as response: + _process_image(await response.read(), out_file) + + async def _upload_to_pastebin(self, text: str) -> Optional[str]: + """Uploads `text` to the paste service, returning the url if successful.""" + try: + async with self.bot.http_session.post( + PASTEBIN_URL + "/documents", + data=text, + raise_for_status=True + ) as response: + response_json = await response.json() + if "key" in response_json: + return f"{PASTEBIN_URL}/{response_json['key']}.txt?noredirect" + except Exception: + # 400 (Bad Request) means there are too many characters + pass + + @commands.command() + @commands.max_concurrency(1, commands.BucketType.guild, wait=True) + @whitelist_override(channels=LATEX_ALLOWED_CHANNNELS) + async def latex(self, ctx: commands.Context, *, query: str) -> None: + """Renders the text in latex and sends the image.""" + query = _prepare_input(query) + + # the hash of the query is used as the filename in the cache. + query_hash = hashlib.md5(query.encode()).hexdigest() + image_path = CACHE_DIRECTORY / f"{query_hash}.png" + async with ctx.typing(): + if not image_path.exists(): + try: + with open(image_path, "wb") as out_file: + await self._generate_image(TEMPLATE.substitute(text=query), out_file) + except InvalidLatexError as err: + embed = discord.Embed(title="Failed to render input.") + if err.logs is None: + embed.description = "No logs available." + else: + logs_paste_url = await self._upload_to_pastebin(err.logs) + if logs_paste_url: + embed.description = f"[View Logs]({logs_paste_url})" + else: + embed.description = "Couldn't upload logs." + await ctx.send(embed=embed) + image_path.unlink() + return + await ctx.send(file=discord.File(image_path, "latex.png")) + + +def setup(bot: Bot) -> None: + """Load the Latex Cog.""" + bot.add_cog(Latex(bot)) diff --git a/bot/exts/fun/uwu.py b/bot/exts/fun/uwu.py new file mode 100644 index 00000000..83497893 --- /dev/null +++ b/bot/exts/fun/uwu.py @@ -0,0 +1,204 @@ +import random +import re +import typing as t +from dataclasses import dataclass +from functools import partial + +import discord +from discord.ext import commands +from discord.ext.commands import Cog, Context, clean_content + +from bot.bot import Bot +from bot.utils import helpers, messages + +WORD_REPLACE = { + "small": "smol", + "cute": "kawaii~", + "fluff": "floof", + "love": "luv", + "stupid": "baka", + "idiot": "baka", + "what": "nani", + "meow": "nya~", + "roar": "rawrr~", +} + +EMOJIS = [ + "rawr x3", + "OwO", + "UwU", + "o.O", + "-.-", + ">w<", + "σωσ", + "òωó", + "ʘwʘ", + ":3", + "XD", + "nyaa~~", + "mya", + ">_<", + "rawr", + "uwu", + "^^", + "^^;;", +] + +REGEX_WORD_REPLACE = re.compile(r"(?<!w)[lr](?!w)") + +REGEX_PUNCTUATION = re.compile(r"[.!?\r\n\t]") + +REGEX_STUTTER = re.compile(r"(\s)([a-zA-Z])") +SUBSTITUTE_STUTTER = r"\g<1>\g<2>-\g<2>" + +REGEX_NYA = re.compile(r"n([aeou][^aeiou])") +SUBSTITUTE_NYA = r"ny\1" + +REGEX_EMOJI = re.compile(r"<(a)?:(\w+?):(\d{15,21}?)>", re.ASCII) + + +@dataclass(frozen=True, eq=True) +class Emoji: + """Data class for an Emoji.""" + + name: str + uid: int + animated: bool = False + + def __str__(self): + anim_bit = "a" if self.animated else "" + return f"<{anim_bit}:{self.name}:{self.uid}>" + + def can_display(self, bot: Bot) -> bool: + """Determines if a bot is in a server with the emoji.""" + return bot.get_emoji(self.uid) is not None + + @classmethod + def from_match(cls, match: tuple[str, str, str]) -> t.Optional['Emoji']: + """Creates an Emoji from a regex match tuple.""" + if not match or len(match) != 3 or not match[2].isdecimal(): + return None + return cls(match[1], int(match[2]), match[0] == "a") + + +class Uwu(Cog): + """Cog for the uwu command.""" + + def __init__(self, bot: Bot): + self.bot = bot + + def _word_replace(self, input_string: str) -> str: + """Replaces words that are keys in the word replacement hash to the values specified.""" + for word, replacement in WORD_REPLACE.items(): + input_string = input_string.replace(word, replacement) + return input_string + + def _char_replace(self, input_string: str) -> str: + """Replace certain characters with 'w'.""" + return REGEX_WORD_REPLACE.sub("w", input_string) + + def _stutter(self, strength: float, input_string: str) -> str: + """Adds stuttering to a string.""" + return REGEX_STUTTER.sub(partial(self._stutter_replace, strength=strength), input_string, 0) + + def _stutter_replace(self, match: re.Match, strength: float = 0.0) -> str: + """Replaces a single character with a stuttered character.""" + match_string = match.group() + if random.random() < strength: + return f"{match_string}-{match_string[-1]}" # Stutter the last character + return match_string + + def _nyaify(self, input_string: str) -> str: + """Nyaifies a string by adding a 'y' between an 'n' and a vowel.""" + return REGEX_NYA.sub(SUBSTITUTE_NYA, input_string, 0) + + def _emoji(self, strength: float, input_string: str) -> str: + """Replaces some punctuation with emoticons.""" + return REGEX_PUNCTUATION.sub(partial(self._emoji_replace, strength=strength), input_string, 0) + + def _emoji_replace(self, match: re.Match, strength: float = 0.0) -> str: + """Replaces a punctuation character with an emoticon.""" + match_string = match.group() + if random.random() < strength: + return f" {random.choice(EMOJIS)} " + return match_string + + def _ext_emoji_replace(self, input_string: str) -> str: + """Replaces any emoji the bot cannot send in input_text with a random emoticons.""" + groups = REGEX_EMOJI.findall(input_string) + emojis = {Emoji.from_match(match) for match in groups} + # Replace with random emoticon if unable to display + emojis_map = { + re.escape(str(e)): random.choice(EMOJIS) + for e in emojis if e and not e.can_display(self.bot) + } + if emojis_map: + # Pattern for all emoji markdowns to be replaced + emojis_re = re.compile("|".join(emojis_map.keys())) + # Replace matches with random emoticon + return emojis_re.sub( + lambda m: emojis_map[re.escape(m.group())], + input_string + ) + # Return original if no replacement + return input_string + + def _uwuify(self, input_string: str, *, stutter_strength: float = 0.2, emoji_strength: float = 0.1) -> str: + """Takes a string and returns an uwuified version of it.""" + input_string = input_string.lower() + input_string = self._word_replace(input_string) + input_string = self._nyaify(input_string) + input_string = self._char_replace(input_string) + input_string = self._stutter(stutter_strength, input_string) + input_string = self._emoji(emoji_strength, input_string) + input_string = self._ext_emoji_replace(input_string) + return input_string + + @commands.command(name="uwu", aliases=("uwuwize", "uwuify",)) + async def uwu_command(self, ctx: Context, *, text: t.Optional[str] = None) -> None: + """ + Echo an uwuified version the passed text. + + Example: + '.uwu Hello, my name is John' returns something like + 'hewwo, m-my name is j-john nyaa~'. + """ + # If `text` isn't provided then we try to get message content of a replied message + text = text or getattr(ctx.message.reference, "resolved", None) + if isinstance(text, discord.Message): + embeds = text.embeds + text = text.content + else: + embeds = None + + if text is None: + # If we weren't able to get the content of a replied message + raise commands.UserInputError("Your message must have content or you must reply to a message.") + + await clean_content(fix_channel_mentions=True).convert(ctx, text) + + # Grabs the text from the embed for uwuification + if embeds: + embed = messages.convert_embed(self._uwuify, embeds[0]) + else: + # Parse potential message links in text + text, embed = await messages.get_text_and_embed(ctx, text) + + # If an embed is found, grab and uwuify its text + if embed: + embed = messages.convert_embed(self._uwuify, embed) + + # Adds the text harvested from an embed to be put into another quote block. + if text: + converted_text = self._uwuify(text) + converted_text = helpers.suppress_links(converted_text) + converted_text = f">>> {converted_text.lstrip('> ')}" + else: + converted_text = None + + await ctx.send(content=converted_text, embed=embed) + + +def setup(bot: Bot) -> None: + """Load the uwu cog.""" + bot.add_cog(Uwu(bot)) |