aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/fun
diff options
context:
space:
mode:
Diffstat (limited to 'bot/exts/fun')
-rw-r--r--bot/exts/fun/battleship.py4
-rw-r--r--bot/exts/fun/connect_four.py5
-rw-r--r--bot/exts/fun/fun.py116
-rw-r--r--bot/exts/fun/latex.py138
-rw-r--r--bot/exts/fun/uwu.py204
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))