aboutsummaryrefslogtreecommitdiffstats
path: root/bot
diff options
context:
space:
mode:
Diffstat (limited to 'bot')
-rw-r--r--bot/__init__.py14
-rw-r--r--bot/bot.py41
-rw-r--r--bot/command.py18
-rw-r--r--bot/constants.py44
-rw-r--r--bot/exts/easter/april_fools_vids.py26
-rw-r--r--bot/exts/easter/avatar_easterifier.py128
-rw-r--r--bot/exts/easter/easter_riddle.py13
-rw-r--r--bot/exts/easter/egg_decorating.py4
-rw-r--r--bot/exts/evergreen/8bitify.py54
-rw-r--r--bot/exts/evergreen/avatar_modification/__init__.py0
-rw-r--r--bot/exts/evergreen/avatar_modification/_effects.py287
-rw-r--r--bot/exts/evergreen/avatar_modification/avatar_modify.py370
-rw-r--r--bot/exts/evergreen/battleship.py2
-rw-r--r--bot/exts/evergreen/catify.py88
-rw-r--r--bot/exts/evergreen/error_handler.py10
-rw-r--r--bot/exts/evergreen/fun.py3
-rw-r--r--bot/exts/evergreen/githubinfo.py143
-rw-r--r--bot/exts/evergreen/help.py4
-rw-r--r--bot/exts/evergreen/issues.py302
-rw-r--r--bot/exts/evergreen/latex.py94
-rw-r--r--bot/exts/evergreen/ping.py44
-rw-r--r--bot/exts/evergreen/reddit.py425
-rw-r--r--bot/exts/evergreen/timed.py46
-rw-r--r--bot/exts/evergreen/uptime.py33
-rw-r--r--bot/exts/evergreen/wolfram.py3
-rw-r--r--bot/exts/halloween/candy_collection.py3
-rw-r--r--bot/exts/halloween/spookyavatar.py52
-rw-r--r--bot/exts/internal_eval/__init__.py10
-rw-r--r--bot/exts/internal_eval/_helpers.py249
-rw-r--r--bot/exts/internal_eval/_internal_eval.py176
-rw-r--r--bot/exts/pride/pride_avatar.py177
-rw-r--r--bot/group.py18
-rw-r--r--bot/resources/easter/april_fools_vids.json251
-rw-r--r--bot/resources/easter/easter_riddle.json8
-rw-r--r--bot/resources/evergreen/py_topics.yaml6
-rw-r--r--bot/resources/evergreen/starter.yaml20
-rw-r--r--bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3bin118125 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3bin112365 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3bin137385 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3bin135405 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3bin162421 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3bin131625 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3bin163257 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3bin131566 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3bin153226 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3bin114773 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3bin298717 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3bin177049 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3bin148276 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3bin62171 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3bin133651 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3bin74718 -> 0 bytes
-rw-r--r--bot/resources/halloween/spookysounds/sources.txt41
-rw-r--r--bot/resources/pride/gender_options.json41
-rw-r--r--bot/utils/converters.py32
-rw-r--r--bot/utils/exceptions.py2
-rw-r--r--bot/utils/helpers.py8
-rw-r--r--bot/utils/messages.py19
-rw-r--r--bot/utils/pagination.py6
59 files changed, 2424 insertions, 891 deletions
diff --git a/bot/__init__.py b/bot/__init__.py
index bdb18666..71b7c8a3 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)")
@@ -56,6 +60,7 @@ if root.handlers:
logging.getLogger("discord").setLevel(logging.ERROR)
logging.getLogger("websockets").setLevel(logging.ERROR)
logging.getLogger("PIL").setLevel(logging.ERROR)
+logging.getLogger("matplotlib").setLevel(logging.ERROR)
# Setup new logging configuration
logging.basicConfig(
@@ -70,3 +75,12 @@ logging.getLogger().info('Logging initialization complete')
# On Windows, the selector event loop is required for aiodns.
if os.name == "nt":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
+
+
+# Monkey-patch discord.py decorators to use the both the Command and Group subclasses which supports root aliases.
+# Must be patched before any cogs are added.
+commands.command = partial(commands.command, cls=Command)
+commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command)
+
+commands.group = partial(commands.group, cls=Group)
+commands.GroupMixin.group = partialmethod(commands.GroupMixin.group, cls=Group)
diff --git a/bot/bot.py b/bot/bot.py
index e9750697..7e495940 100644
--- a/bot/bot.py
+++ b/bot/bot.py
@@ -64,6 +64,26 @@ class Bot(commands.Bot):
super().add_cog(cog)
log.info(f"Cog loaded: {cog.qualified_name}")
+ def add_command(self, command: commands.Command) -> None:
+ """Add `command` as normal and then add its root aliases to the bot."""
+ super().add_command(command)
+ self._add_root_aliases(command)
+
+ def remove_command(self, name: str) -> Optional[commands.Command]:
+ """
+ Remove a command/alias as normal and then remove its root aliases from the bot.
+
+ Individual root aliases cannot be removed by this function.
+ To remove them, either remove the entire command or manually edit `bot.all_commands`.
+ """
+ command = super().remove_command(name)
+ if command is None:
+ # Even if it's a root alias, there's no way to get the Bot instance to remove the alias.
+ return
+
+ self._remove_root_aliases(command)
+ return command
+
async def on_command_error(self, context: commands.Context, exception: DiscordException) -> None:
"""Check command errors for UserInputError and reset the cooldown if thrown."""
if isinstance(exception, commands.UserInputError):
@@ -139,6 +159,27 @@ class Bot(commands.Bot):
"""
await self._guild_available.wait()
+ def _add_root_aliases(self, command: commands.Command) -> None:
+ """Recursively add root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._add_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ if alias in self.all_commands:
+ raise commands.CommandRegistrationError(alias, alias_conflict=True)
+
+ self.all_commands[alias] = command
+
+ def _remove_root_aliases(self, command: commands.Command) -> None:
+ """Recursively remove root aliases for `command` and any of its subcommands."""
+ if isinstance(command, commands.Group):
+ for subcommand in command.commands:
+ self._remove_root_aliases(subcommand)
+
+ for alias in getattr(command, "root_aliases", ()):
+ self.all_commands.pop(alias, None)
+
_allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES]
diff --git a/bot/command.py b/bot/command.py
new file mode 100644
index 00000000..0fb900f7
--- /dev/null
+++ b/bot/command.py
@@ -0,0 +1,18 @@
+from discord.ext import commands
+
+
+class Command(commands.Command):
+ """
+ A `discord.ext.commands.Command` subclass which supports root aliases.
+
+ A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as
+ top-level commands rather than being aliases of the command's group. It's stored as an attribute
+ also named `root_aliases`.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.root_aliases = kwargs.get("root_aliases", [])
+
+ if not isinstance(self.root_aliases, (list, tuple)):
+ raise TypeError("Root aliases of a command must be a list or a tuple of strings.")
diff --git a/bot/constants.py b/bot/constants.py
index 416dd0e7..549d01b6 100644
--- a/bot/constants.py
+++ b/bot/constants.py
@@ -8,6 +8,7 @@ from typing import Dict, NamedTuple
__all__ = (
"AdventOfCode",
"Branding",
+ "Cats",
"Channels",
"Categories",
"Client",
@@ -19,6 +20,7 @@ __all__ = (
"Roles",
"Tokens",
"Wolfram",
+ "Reddit",
"RedisConfig",
"MODERATION_ROLES",
"STAFF_ROLES",
@@ -93,6 +95,10 @@ class Branding:
cycle_frequency = int(environ.get("CYCLE_FREQUENCY", 3)) # 0: never, 1: every day, 2: every other day, ...
+class Cats:
+ cats = ["ᓚᘏᗢ", "ᘡᘏᗢ", "🐈", "ᓕᘏᗢ", "ᓇᘏᗢ", "ᓂᘏᗢ", "ᘣᘏᗢ", "ᕦᘏᗢ", "ᕂᘏᗢ"]
+
+
class Channels(NamedTuple):
advent_of_code = int(environ.get("AOC_CHANNEL_ID", 782715290437943306))
advent_of_code_commands = int(environ.get("AOC_COMMANDS_CHANNEL_ID", 607247579608121354))
@@ -110,6 +116,7 @@ class Channels(NamedTuple):
voice_chat_0 = 412357430186344448
voice_chat_1 = 799647045886541885
staff_voice = 541638762007101470
+ reddit = int(environ.get("CHANNEL_REDDIT", 458224812528238616))
class Categories(NamedTuple):
@@ -147,13 +154,30 @@ 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"
envelope = "\U0001F4E8"
- trashcan = "<:trashcan:637136429717389331>"
+ trashcan = environ.get("TRASHCAN_EMOJI", "<:trashcan:637136429717389331>")
ok_hand = ":ok_hand:"
hand_raised = "\U0001f64b"
@@ -168,6 +192,7 @@ class Emojis:
issue_closed = "<:IssueClosed:629695470570307614>"
pull_request = "<:PROpen:629695470175780875>"
pull_request_closed = "<:PRClosed:629695470519713818>"
+ pull_request_draft = "<:PRDraft:829755345425399848>"
merge = "<:PRMerged:629695470570176522>"
number_emojis = {
@@ -194,6 +219,15 @@ class Emojis:
status_dnd = "<:status_dnd:470326272082313216>"
status_offline = "<:status_offline:470326266537705472>"
+ # Reddit emojis
+ reddit = "<:reddit:676030265734332427>"
+ reddit_post_text = "<:reddit_post_text:676030265910493204>"
+ reddit_post_video = "<:reddit_post_video:676030265839190047>"
+ reddit_post_photo = "<:reddit_post_photo:676030265734201344>"
+ reddit_upvote = "<:reddit_upvote:755845219890757644>"
+ reddit_comments = "<:reddit_comments:755845255001014384>"
+ reddit_users = "<:reddit_users:755845303822974997>"
+
class Icons:
questionmark = "https://cdn.discordapp.com/emojis/512367613339369475.png"
@@ -270,6 +304,14 @@ class Source:
github_avatar_url = "https://avatars1.githubusercontent.com/u/9919"
+class Reddit:
+ subreddits = ["r/Python"]
+
+ client_id = environ.get("REDDIT_CLIENT_ID")
+ secret = environ.get("REDDIT_SECRET")
+ webhook = int(environ.get("REDDIT_WEBHOOK", 635408384794951680))
+
+
# Default role combinations
MODERATION_ROLES = Roles.moderator, Roles.admin, Roles.owner
STAFF_ROLES = Roles.helpers, Roles.moderator, Roles.admin, Roles.owner
diff --git a/bot/exts/easter/april_fools_vids.py b/bot/exts/easter/april_fools_vids.py
index efe7e677..c7a3c014 100644
--- a/bot/exts/easter/april_fools_vids.py
+++ b/bot/exts/easter/april_fools_vids.py
@@ -1,36 +1,26 @@
import logging
import random
from json import load
-from pathlib import Path
from discord.ext import commands
log = logging.getLogger(__name__)
+with open("bot/resources/easter/april_fools_vids.json", encoding="utf-8") as f:
+ ALL_VIDS = load(f)
+
class AprilFoolVideos(commands.Cog):
"""A cog for April Fools' that gets a random April Fools' video from Youtube."""
- def __init__(self, bot: commands.Bot):
- self.bot = bot
- self.yt_vids = self.load_json()
- self.youtubers = ['google'] # will add more in future
-
- @staticmethod
- def load_json() -> dict:
- """A function to load JSON data."""
- p = Path('bot/resources/easter/april_fools_vids.json')
- with p.open(encoding="utf-8") as json_file:
- all_vids = load(json_file)
- return all_vids
-
@commands.command(name='fool')
async def april_fools(self, ctx: commands.Context) -> None:
"""Get a random April Fools' video from Youtube."""
- random_youtuber = random.choice(self.youtubers)
- category = self.yt_vids[random_youtuber]
- random_vid = random.choice(category)
- await ctx.send(f"Check out this April Fools' video by {random_youtuber}.\n\n{random_vid['link']}")
+ video = random.choice(ALL_VIDS)
+
+ channel, url = video["channel"], video["url"]
+
+ await ctx.send(f"Check out this April Fools' video by {channel}.\n\n{url}")
def setup(bot: commands.Bot) -> None:
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/easter/easter_riddle.py b/bot/exts/easter/easter_riddle.py
index 3c612eb1..a93b3745 100644
--- a/bot/exts/easter/easter_riddle.py
+++ b/bot/exts/easter/easter_riddle.py
@@ -7,7 +7,7 @@ from pathlib import Path
import discord
from discord.ext import commands
-from bot.constants import Colours
+from bot.constants import Colours, NEGATIVE_REPLIES
log = logging.getLogger(__name__)
@@ -36,6 +36,17 @@ class EasterRiddle(commands.Cog):
if self.current_channel:
return await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!")
+ # Don't let users start in a DM
+ if not ctx.guild:
+ await ctx.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="You can't start riddles in DMs",
+ colour=discord.Colour.red()
+ )
+ )
+ return
+
self.current_channel = ctx.message.channel
random_question = random.choice(RIDDLE_QUESTIONS)
diff --git a/bot/exts/easter/egg_decorating.py b/bot/exts/easter/egg_decorating.py
index b18e6636..a847388d 100644
--- a/bot/exts/easter/egg_decorating.py
+++ b/bot/exts/easter/egg_decorating.py
@@ -10,6 +10,8 @@ import discord
from PIL import Image
from discord.ext import commands
+from bot.utils import helpers
+
log = logging.getLogger(__name__)
with open(Path("bot/resources/evergreen/html_colours.json"), encoding="utf8") as f:
@@ -65,7 +67,7 @@ class EggDecorating(commands.Cog):
if value:
colours[idx] = discord.Colour(value)
else:
- invalid.append(colour)
+ invalid.append(helpers.suppress_links(colour))
if len(invalid) > 1:
return await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}")
diff --git a/bot/exts/evergreen/8bitify.py b/bot/exts/evergreen/8bitify.py
deleted file mode 100644
index 54e68f80..00000000
--- a/bot/exts/evergreen/8bitify.py
+++ /dev/null
@@ -1,54 +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():
- image_bytes = await ctx.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/avatar_modification/__init__.py b/bot/exts/evergreen/avatar_modification/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/__init__.py
diff --git a/bot/exts/evergreen/avatar_modification/_effects.py b/bot/exts/evergreen/avatar_modification/_effects.py
new file mode 100644
index 00000000..d2370b4b
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/_effects.py
@@ -0,0 +1,287 @@
+import math
+import random
+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) -> 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.Image, px: int) -> Image.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.Image, pixels: int, flag: str) -> Image.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) -> 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.Image, overlay_image: t.Optional[Image.Image] = None) -> Image.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
+
+ @staticmethod
+ def split_image(img: Image.Image, squares: int) -> list:
+ """
+ Split an image into a selection of squares, specified by the squares argument.
+
+ Explanation:
+
+ 1. It gets the width and the height of the Image passed to the function.
+
+ 2. It gets the root of a number of squares (number of squares) passed, which is called xy. Reason: if let's say
+ 25 squares (number of squares) were passed, that is the total squares (split pieces) that the image is supposed
+ to be split into. As it is known, a 2D shape has a height and a width, and in this case the program thinks of it
+ as rows and columns. Rows multiplied by columns is equal to the passed squares (number of squares). To get rows
+ and columns, since in this case, each square (split piece) is identical, rows are equal to columns and the
+ program treats the image as a square-shaped, it gets the root out of the squares (number of squares) passed.
+
+ 3. Now width and height are both of the original Image, Discord PFP, so when it comes to forming the squares,
+ the program divides the original image's height and width by the xy. In a case of 25 squares (number of squares)
+ passed, xy would be 5, so if an image was 250x300, x_frac would be 50 and y_frac - 60. Note:
+ x_frac stands for a fracture of width. The reason it's called that is because it is shorter to use x for width
+ in mind and then it's just half of the word fracture, same applies to y_frac, just height instead of width.
+ x_frac and y_frac are width and height of a single square (split piece).
+
+ 4. With left, top, right, bottom, = 0, 0, x_frac, y_frac, the program sets these variables to create the initial
+ square (split piece). Explanation: all of these 4 variables start at the top left corner of the Image, by adding
+ value to right and bottom, it's creating the initial square (split piece).
+
+ 5. In the for loop, it keeps adding those squares (split pieces) in a row and once (index + 1) % xy == 0 is
+ True, it adds to top and bottom to lower them and reset right and left to recreate the initial space between
+ them, forming a square (split piece), it also adds the newly created square (split piece) into the new_imgs list
+ where it stores them. The program keeps repeating this process till all 25 squares get added to the list.
+
+ 6. It returns new_imgs, a list of squares (split pieces).
+ """
+ width, heigth = img.size
+
+ xy = math.sqrt(squares)
+
+ x_frac = width // xy
+ y_frac = heigth // xy
+
+ left, top, right, bottom, = 0, 0, x_frac, y_frac
+
+ new_imgs = []
+
+ for index in range(squares):
+ new_img = img.crop((left, top, right, bottom))
+ new_imgs.append(new_img)
+
+ if (index + 1) % xy == 0:
+ top += y_frac
+ bottom += y_frac
+ left = 0
+ right = x_frac
+ else:
+ left += x_frac
+ right += x_frac
+
+ return new_imgs
+
+ @staticmethod
+ def join_images(images: t.List[Image.Image]) -> Image.Image:
+ """
+ Stitches all the image squares into a new image.
+
+ Explanation:
+
+ 1. Shuffles the passed images to randomize the pieces.
+
+ 2. The program gets a single square (split piece) out of the list and defines single_width as the square's width
+ and single_height as the square's height.
+
+ 3. It gets the root of type integer of the number of images (split pieces) in the list and calls it multiplier.
+ Program then proceeds to calculate total height and width of the new image that it's creating using the same
+ multiplier.
+
+ 4. The program then defines new_image as the image that it's creating, using the previously obtained total_width
+ and total_height.
+
+ 5. Now it defines width_multiplier as well as height with values of 0. These will be used to correctly position
+ squares (split pieces) onto the new_image canvas.
+
+ 6. Similar to how in the split_image function, the program gets the root of number of images in the list.
+ In split_image function, it was the passed squares (number of squares) instead of a number of imgs in the
+ list that it got the square of here.
+
+ 7. In the for loop, as it iterates, the program multiplies single_width by width_multiplier to correctly
+ position a square (split piece) width wise. It then proceeds to paste the newly positioned square (split piece)
+ onto the new_image. The program increases the width_multiplier by 1 every iteration so the image wouldn't get
+ pasted in the same spot and the positioning would move accordingly. It makes sure to increase the
+ width_multiplier before the check, which checks if the end of a row has been reached, -
+ (index + 1) % pieces == 0, so after it, if it was True, width_multiplier would have been reset to 0 (start of
+ the row). If the check returns True, the height gets increased by a single square's (split piece) height to
+ lower the positioning height wise and, as mentioned, the width_multiplier gets reset to 0 and width will
+ then be calculated from the start of the new row. The for loop finishes once all the squares (split pieces) were
+ positioned accordingly.
+
+ 8. Finally, it returns the new_image, the randomized squares (split pieces) stitched back into the format of the
+ original image - user's PFP.
+ """
+ random.shuffle(images)
+ single_img = images[0]
+
+ single_wdith = single_img.size[0]
+ single_height = single_img.size[1]
+
+ multiplier = int(math.sqrt(len(images)))
+
+ total_width = multiplier * single_wdith
+ total_height = multiplier * single_height
+
+ new_image = Image.new('RGBA', (total_width, total_height), (250, 250, 250))
+
+ width_multiplier = 0
+ height = 0
+
+ squares = math.sqrt(len(images))
+
+ for index, image in enumerate(images):
+ width = single_wdith * width_multiplier
+
+ new_image.paste(image, (width, height))
+
+ width_multiplier += 1
+
+ if (index + 1) % squares == 0:
+ width_multiplier = 0
+ height += single_height
+
+ return new_image
+
+ @staticmethod
+ def mosaic_effect(img_bytes: bytes, squares: int, file_name: str) -> discord.File:
+ """Seperate function run from an executor which turns an image into a mosaic."""
+ avatar = Image.open(BytesIO(img_bytes))
+ avatar = avatar.convert('RGBA').resize((1024, 1024))
+
+ img_squares = PfpEffects.split_image(avatar, squares)
+ new_img = PfpEffects.join_images(img_squares)
+
+ bufferedio = BytesIO()
+ new_img.save(bufferedio, format='PNG')
+ bufferedio.seek(0)
+
+ return discord.File(bufferedio, filename=file_name)
diff --git a/bot/exts/evergreen/avatar_modification/avatar_modify.py b/bot/exts/evergreen/avatar_modification/avatar_modify.py
new file mode 100644
index 00000000..693d15c7
--- /dev/null
+++ b/bot/exts/evergreen/avatar_modification/avatar_modify.py
@@ -0,0 +1,370 @@
+import asyncio
+import json
+import logging
+import math
+import string
+import typing as t
+import unicodedata
+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 Colours, Emojis
+from bot.exts.evergreen.avatar_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"
+
+MAX_SQUARES = 10_000
+
+T = t.TypeVar("T")
+
+with open("bot/resources/pride/gender_options.json") as f:
+ GENDER_OPTIONS = json.load(f)
+
+
+async def in_executor(func: t.Callable[..., T], *args) -> T:
+ """
+ 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)
+
+
+def file_safe_name(effect: str, display_name: str) -> str:
+ """Returns a file safe filename based on the given effect and display name."""
+ valid_filename_chars = f"-_. {string.ascii_letters}{string.digits}"
+
+ file_name = FILENAME_STRING.format(effect=effect, author=display_name)
+
+ # Replace spaces
+ file_name = file_name.replace(" ", "_")
+
+ # Normalize unicode characters
+ cleaned_filename = unicodedata.normalize("NFKD", file_name).encode("ASCII", "ignore").decode()
+
+ # Remove invalid filename characters
+ cleaned_filename = "".join(c for c in cleaned_filename if c in valid_filename_chars)
+ return cleaned_filename
+
+
+class AvatarModify(commands.Cog):
+ """Various commands for users to apply affects to their own avatars."""
+
+ def __init__(self, bot: commands.Bot) -> None:
+ self.bot = bot
+
+ async def _fetch_user(self, user_id: int) -> t.Optional[discord.User]:
+ """
+ Fetches a user and handles errors.
+
+ This helper function 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.
+ fetch_member can't be used due to the avatar url being part of the user object, and
+ some weird caching that D.py does
+ """
+ try:
+ user = await self.bot.fetch_user(user_id)
+ except discord.errors.NotFound:
+ log.debug(f"User {user_id} could not be found.")
+ return None
+ except discord.HTTPException:
+ log.exception(f"Exception while trying to retrieve user {user_id} from Discord.")
+ return None
+
+ return user
+
+ @commands.group(aliases=("avatar_mod", "pfp_mod", "avatarmod", "pfpmod"))
+ async def avatar_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)
+
+ @avatar_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():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ image_bytes = await user.avatar_url_as(size=1024).read()
+ file_name = file_safe_name("eightbit_avatar", ctx.author.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 {ctx.author.display_name}.", icon_url=user.avatar_url)
+
+ await ctx.send(embed=embed, file=file)
+
+ @avatar_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():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user 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 user.avatar_url_as(size=256).read()
+ file_name = file_safe_name("easterified_avatar", ctx.author.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 {ctx.author.display_name}.", icon_url=user.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 = file_safe_name("pride_avatar", 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)
+
+ @avatar_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():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+ image_bytes = await user.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)
+
+ @avatar_modify.command(
+ aliases=("savatar", "spookify"),
+ root_aliases=("spookyavatar", "spookify", "savatar"),
+ brief="Spookify an user's avatar."
+ )
+ async def spookyavatar(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
+
+ user = await self._fetch_user(member.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ async with ctx.typing():
+ image_bytes = await user.avatar_url_as(size=1024).read()
+
+ file_name = file_safe_name("spooky_avatar", 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)
+
+ @avatar_modify.command(name="mosaic", root_aliases=("mosaic",))
+ async def mosaic_command(self, ctx: commands.Context, squares: int = 16) -> None:
+ """Splits your avatar into x squares, randomizes them and stitches them back into a new image!"""
+ async with ctx.typing():
+ user = await self._fetch_user(ctx.author.id)
+ if not user:
+ await ctx.send(f"{Emojis.cross_mark} Could not get user info.")
+ return
+
+ if not 1 <= squares <= MAX_SQUARES:
+ raise commands.BadArgument(f"Squares must be a positive number less than or equal to {MAX_SQUARES:,}.")
+
+ sqrt = math.sqrt(squares)
+
+ if not sqrt.is_integer():
+ squares = math.ceil(sqrt) ** 2 # Get the next perfect square
+
+ file_name = file_safe_name("mosaic_avatar", ctx.author.display_name)
+
+ img_bytes = await user.avatar_url_as(size=1024).read()
+
+ file = await in_executor(
+ PfpEffects.mosaic_effect,
+ img_bytes,
+ squares,
+ file_name
+ )
+
+ if squares == 1:
+ title = "Hooh... that was a lot of work"
+ description = "I present to you... Yourself!"
+ elif squares == MAX_SQUARES:
+ title = "Testing the limits I see..."
+ description = "What a masterpiece. :star:"
+ else:
+ title = "Your mosaic avatar"
+ description = f"Here is your avatar. I think it looks a bit *puzzling*\nMade with {squares} squares."
+
+ embed = discord.Embed(
+ title=title,
+ description=description,
+ colour=Colours.blue
+ )
+
+ embed.set_image(url=f"attachment://{file_name}")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=user.avatar_url)
+
+ await ctx.send(file=file, embed=embed)
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the AvatarModify cog."""
+ bot.add_cog(AvatarModify(bot))
diff --git a/bot/exts/evergreen/battleship.py b/bot/exts/evergreen/battleship.py
index fa3fb35c..1681434f 100644
--- a/bot/exts/evergreen/battleship.py
+++ b/bot/exts/evergreen/battleship.py
@@ -227,7 +227,7 @@ class Game:
if message.content.lower() == "surrender":
self.surrender = True
return True
- self.match = re.match("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
+ self.match = re.fullmatch("([A-J]|[a-j]) ?((10)|[1-9])", message.content.strip())
if not self.match:
self.bot.loop.create_task(message.add_reaction(CROSS_EMOJI))
return bool(self.match)
diff --git a/bot/exts/evergreen/catify.py b/bot/exts/evergreen/catify.py
new file mode 100644
index 00000000..a175602f
--- /dev/null
+++ b/bot/exts/evergreen/catify.py
@@ -0,0 +1,88 @@
+import random
+from contextlib import suppress
+from typing import Optional
+
+from discord import AllowedMentions, Embed, Forbidden
+from discord.ext import commands
+
+from bot.constants import Cats, Colours, NEGATIVE_REPLIES
+from bot.utils import helpers
+
+
+class Catify(commands.Cog):
+ """Cog for the catify command."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(aliases=["ᓚᘏᗢify", "ᓚᘏᗢ"])
+ @commands.cooldown(1, 5, commands.BucketType.user)
+ async def catify(self, ctx: commands.Context, *, text: Optional[str]) -> None:
+ """
+ Convert the provided text into a cat themed sentence by interspercing cats throughout text.
+
+ If no text is given then the users nickname is edited.
+ """
+ if not text:
+ display_name = ctx.author.display_name
+
+ if len(display_name) > 26:
+ embed = Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=(
+ "Your display name is too long to be catified! "
+ "Please change it to be under 26 characters."
+ ),
+ color=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+ return
+
+ else:
+ display_name += f" | {random.choice(Cats.cats)}"
+
+ await ctx.send(f"Your catified nickname is: `{display_name}`", allowed_mentions=AllowedMentions.none())
+
+ with suppress(Forbidden):
+ await ctx.author.edit(nick=display_name)
+ else:
+ if len(text) >= 1500:
+ embed = Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="Submitted text was too large! Please submit something under 1500 characters.",
+ color=Colours.soft_red
+ )
+ await ctx.send(embed=embed)
+ return
+
+ string_list = text.split()
+ for index, name in enumerate(string_list):
+ name = name.lower()
+ if "cat" in name:
+ if random.randint(0, 5) == 5:
+ string_list[index] = name.replace("cat", f"**{random.choice(Cats.cats)}**")
+ else:
+ string_list[index] = name.replace("cat", random.choice(Cats.cats))
+ for element in Cats.cats:
+ if element in name:
+ string_list[index] = name.replace(element, "cat")
+
+ string_len = len(string_list) // 3 or len(string_list)
+
+ for _ in range(random.randint(1, string_len)):
+ # insert cat at random index
+ if random.randint(0, 5) == 5:
+ string_list.insert(random.randint(0, len(string_list)), f"**{random.choice(Cats.cats)}**")
+ else:
+ string_list.insert(random.randint(0, len(string_list)), random.choice(Cats.cats))
+
+ text = helpers.suppress_links(" ".join(string_list))
+ await ctx.send(
+ f">>> {text}",
+ allowed_mentions=AllowedMentions.none()
+ )
+
+
+def setup(bot: commands.Bot) -> None:
+ """Loads the catify cog."""
+ bot.add_cog(Catify(bot))
diff --git a/bot/exts/evergreen/error_handler.py b/bot/exts/evergreen/error_handler.py
index 28902503..8db49748 100644
--- a/bot/exts/evergreen/error_handler.py
+++ b/bot/exts/evergreen/error_handler.py
@@ -46,6 +46,11 @@ class CommandErrorHandler(commands.Cog):
logging.debug(f"Command {ctx.command} had its error already handled locally; ignoring.")
return
+ parent_command = ""
+ if subctx := getattr(ctx, "subcontext", None):
+ parent_command = f"{ctx.command} "
+ ctx = subctx
+
error = getattr(error, 'original', error)
logging.debug(
f"Error Encountered: {type(error).__name__} - {str(error)}, "
@@ -63,8 +68,9 @@ class CommandErrorHandler(commands.Cog):
if isinstance(error, commands.UserInputError):
self.revert_cooldown_counter(ctx.command, ctx.message)
+ usage = f"```{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}```"
embed = self.error_embed(
- f"Your input was invalid: {error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ f"Your input was invalid: {error}\n\nUsage:{usage}"
)
await ctx.send(embed=embed)
return
@@ -95,7 +101,7 @@ class CommandErrorHandler(commands.Cog):
self.revert_cooldown_counter(ctx.command, ctx.message)
embed = self.error_embed(
"The argument you provided was invalid: "
- f"{error}\n\nUsage:\n```{ctx.prefix}{ctx.command} {ctx.command.signature}```"
+ f"{error}\n\nUsage:\n```{ctx.prefix}{parent_command}{ctx.command} {ctx.command.signature}```"
)
await ctx.send(embed=embed)
return
diff --git a/bot/exts/evergreen/fun.py b/bot/exts/evergreen/fun.py
index 101725da..7152d0cb 100644
--- a/bot/exts/evergreen/fun.py
+++ b/bot/exts/evergreen/fun.py
@@ -11,6 +11,7 @@ from discord.ext.commands import BadArgument, Bot, Cog, Context, MessageConverte
from bot import utils
from bot.constants import Client, Colours, Emojis
+from bot.utils import helpers
log = logging.getLogger(__name__)
@@ -83,6 +84,7 @@ class Fun(Cog):
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('> ')}"
@@ -101,6 +103,7 @@ class Fun(Cog):
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('> ')}"
diff --git a/bot/exts/evergreen/githubinfo.py b/bot/exts/evergreen/githubinfo.py
index 2e38e3ab..c8a6b3f7 100644
--- a/bot/exts/evergreen/githubinfo.py
+++ b/bot/exts/evergreen/githubinfo.py
@@ -1,16 +1,19 @@
import logging
import random
from datetime import datetime
-from typing import Optional
+from urllib.parse import quote
import discord
from discord.ext import commands
from discord.ext.commands.cooldowns import BucketType
-from bot.constants import NEGATIVE_REPLIES
+from bot.constants import Colours, NEGATIVE_REPLIES
+from bot.exts.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
+GITHUB_API_URL = "https://api.github.com"
+
class GithubInfo(commands.Cog):
"""Fetches info from GitHub."""
@@ -23,27 +26,28 @@ class GithubInfo(commands.Cog):
async with self.bot.http_session.get(url) as r:
return await r.json()
- @commands.command(name='github', aliases=['gh'])
- @commands.cooldown(1, 60, BucketType.user)
- async def get_github_info(self, ctx: commands.Context, username: Optional[str]) -> None:
- """
- Fetches a user's GitHub information.
-
- Username is optional and sends the help command if not specified.
- """
- if username is None:
- await ctx.invoke(self.bot.get_command('help'), 'github')
- ctx.command.reset_cooldown(ctx)
- return
+ @commands.group(name='github', aliases=('gh', 'git'))
+ @commands.cooldown(1, 10, BucketType.user)
+ async def github_group(self, ctx: commands.Context) -> None:
+ """Commands for finding information related to GitHub."""
+ if ctx.invoked_subcommand is None:
+ await invoke_help_command(ctx)
+ @github_group.command(name='user', aliases=('userinfo',))
+ async def github_user_info(self, ctx: commands.Context, username: str) -> None:
+ """Fetches a user's GitHub information."""
async with ctx.typing():
- user_data = await self.fetch_data(f"https://api.github.com/users/{username}")
+ user_data = await self.fetch_data(f"{GITHUB_API_URL}/users/{username}")
# User_data will not have a message key if the user exists
- if user_data.get('message') is not None:
- await ctx.send(embed=discord.Embed(title=random.choice(NEGATIVE_REPLIES),
- description=f"The profile for `{username}` was not found.",
- colour=discord.Colour.red()))
+ if "message" in user_data:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=f"The profile for `{username}` was not found.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
return
org_data = await self.fetch_data(user_data['organizations_url'])
@@ -63,7 +67,7 @@ class GithubInfo(commands.Cog):
embed = discord.Embed(
title=f"`{user_data['login']}`'s GitHub profile info",
description=f"```{user_data['bio']}```\n" if user_data['bio'] is not None else "",
- colour=0x7289da,
+ colour=discord.Colour.blurple(),
url=user_data['html_url'],
timestamp=datetime.strptime(user_data['created_at'], "%Y-%m-%dT%H:%M:%SZ")
)
@@ -72,26 +76,99 @@ class GithubInfo(commands.Cog):
if user_data['type'] == "User":
- embed.add_field(name="Followers",
- value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)")
- embed.add_field(name="\u200b", value="\u200b")
- embed.add_field(name="Following",
- value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)")
-
- embed.add_field(name="Public repos",
- value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)")
- embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(
+ name="Followers",
+ value=f"[{user_data['followers']}]({user_data['html_url']}?tab=followers)"
+ )
+ embed.add_field(
+ name="Following",
+ value=f"[{user_data['following']}]({user_data['html_url']}?tab=following)"
+ )
+
+ embed.add_field(
+ name="Public repos",
+ value=f"[{user_data['public_repos']}]({user_data['html_url']}?tab=repositories)"
+ )
if user_data['type'] == "User":
- embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{username})")
+ embed.add_field(name="Gists", value=f"[{gists}](https://gist.github.com/{quote(username, safe='')})")
- embed.add_field(name=f"Organization{'s' if len(orgs)!=1 else ''}",
- value=orgs_to_add if orgs else "No organizations")
- embed.add_field(name="\u200b", value="\u200b")
+ embed.add_field(
+ name=f"Organization{'s' if len(orgs)!=1 else ''}",
+ value=orgs_to_add if orgs else "No organizations"
+ )
embed.add_field(name="Website", value=blog)
await ctx.send(embed=embed)
+ @github_group.command(name='repository', aliases=('repo',))
+ async def github_repo_info(self, ctx: commands.Context, *repo: str) -> None:
+ """
+ Fetches a repositories' GitHub information.
+
+ The repository should look like `user/reponame` or `user reponame`.
+ """
+ repo = '/'.join(repo)
+ if repo.count('/') != 1:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The repository should look like `user/reponame` or `user reponame`.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+ return
+
+ async with ctx.typing():
+ repo_data = await self.fetch_data(f"{GITHUB_API_URL}/repos/{quote(repo)}")
+
+ # There won't be a message key if this repo exists
+ if "message" in repo_data:
+ embed = discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description="The requested repository was not found.",
+ colour=Colours.soft_red
+ )
+
+ await ctx.send(embed=embed)
+ return
+
+ embed = discord.Embed(
+ title=repo_data['name'],
+ description=repo_data["description"],
+ colour=discord.Colour.blurple(),
+ url=repo_data['html_url']
+ )
+
+ # If it's a fork, then it will have a parent key
+ try:
+ parent = repo_data["parent"]
+ embed.description += f"\n\nForked from [{parent['full_name']}]({parent['html_url']})"
+ except KeyError:
+ log.debug("Repository is not a fork.")
+
+ repo_owner = repo_data['owner']
+
+ embed.set_author(
+ name=repo_owner["login"],
+ url=repo_owner["html_url"],
+ icon_url=repo_owner["avatar_url"]
+ )
+
+ repo_created_at = datetime.strptime(repo_data['created_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y")
+ last_pushed = datetime.strptime(repo_data['pushed_at'], "%Y-%m-%dT%H:%M:%SZ").strftime("%d/%m/%Y at %H:%M")
+
+ embed.set_footer(
+ text=(
+ f"{repo_data['forks_count']} ⑂ "
+ f"• {repo_data['stargazers_count']} ⭐ "
+ f"• Created At {repo_created_at} "
+ f"• Last Commit {last_pushed}"
+ )
+ )
+
+ await ctx.send(embed=embed)
+
def setup(bot: commands.Bot) -> None:
"""Adding the cog to the 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/issues.py b/bot/exts/evergreen/issues.py
index bbcbf611..a0316080 100644
--- a/bot/exts/evergreen/issues.py
+++ b/bot/exts/evergreen/issues.py
@@ -2,12 +2,23 @@ import logging
import random
import re
import typing as t
-from enum import Enum
+from dataclasses import dataclass
import discord
-from discord.ext import commands, tasks
-
-from bot.constants import Categories, Channels, Colours, ERROR_REPLIES, Emojis, Tokens, WHITELISTED_CHANNELS
+from discord.ext import commands
+
+from bot.constants import (
+ Categories,
+ Channels,
+ Colours,
+ ERROR_REPLIES,
+ Emojis,
+ NEGATIVE_REPLIES,
+ Tokens,
+ WHITELISTED_CHANNELS
+)
+from bot.utils.decorators import whitelist_override
+from bot.utils.extensions import invoke_help_command
log = logging.getLogger(__name__)
@@ -15,20 +26,20 @@ BAD_RESPONSE = {
404: "Issue/pull request not located! Please enter a valid number!",
403: "Rate limit has been hit! Please try again later!"
}
+REQUEST_HEADERS = {
+ "Accept": "application/vnd.github.v3+json"
+}
-MAX_REQUESTS = 10
-REQUEST_HEADERS = dict()
+REPOSITORY_ENDPOINT = "https://api.github.com/orgs/{org}/repos?per_page=100&type=public"
+ISSUE_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/issues/{number}"
+PR_ENDPOINT = "https://api.github.com/repos/{user}/{repository}/pulls/{number}"
-REPOS_API = "https://api.github.com/orgs/{org}/repos"
if GITHUB_TOKEN := Tokens.github:
REQUEST_HEADERS["Authorization"] = f"token {GITHUB_TOKEN}"
WHITELISTED_CATEGORIES = (
Categories.development, Categories.devprojects, Categories.media, Categories.staff
)
-WHITELISTED_CHANNELS_ON_MESSAGE = (
- Channels.organisation, Channels.mod_meta, Channels.mod_tools, Channels.staff_voice
-)
CODE_BLOCK_RE = re.compile(
r"^`([^`\n]+)`" # Inline codeblock
@@ -36,12 +47,45 @@ CODE_BLOCK_RE = re.compile(
re.DOTALL | re.MULTILINE
)
+# Maximum number of issues in one message
+MAXIMUM_ISSUES = 5
+
+# Regex used when looking for automatic linking in messages
+# regex101 of current regex https://regex101.com/r/V2ji8M/6
+AUTOMATIC_REGEX = re.compile(
+ r"((?P<org>[a-zA-Z0-9][a-zA-Z0-9\-]{1,39})\/)?(?P<repo>[\w\-\.]{1,100})#(?P<number>[0-9]+)"
+)
+
+
+@dataclass
+class FoundIssue:
+ """Dataclass representing an issue found by the regex."""
+
+ organisation: t.Optional[str]
+ repository: str
+ number: str
+
+ def __hash__(self) -> int:
+ return hash((self.organisation, self.repository, self.number))
+
+
+@dataclass
+class FetchError:
+ """Dataclass representing an error while fetching an issue."""
+
+ return_code: int
+ message: str
-class FetchIssueErrors(Enum):
- """Errors returned in fetch issues."""
- value_error = "Numbers not found."
- max_requests = "Max requests hit."
+@dataclass
+class IssueState:
+ """Dataclass representing the state of an issue."""
+
+ repository: str
+ number: int
+ url: str
+ title: str
+ emoji: str
class Issues(commands.Cog):
@@ -50,97 +94,96 @@ class Issues(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
self.repos = []
- self.get_pydis_repos.start()
-
- @tasks.loop(minutes=30)
- async def get_pydis_repos(self) -> None:
- """Get all python-discord repositories on github."""
- async with self.bot.http_session.get(REPOS_API.format(org="python-discord")) as resp:
- if resp.status == 200:
- data = await resp.json()
- for repo in data:
- self.repos.append(repo["full_name"].split("/")[1])
- self.repo_regex = "|".join(self.repos)
- else:
- log.debug(f"Failed to get latest Pydis repositories. Status code {resp.status}")
@staticmethod
- def check_in_block(message: discord.Message, repo_issue: str) -> bool:
- """Check whether the <repo>#<issue> is in codeblocks."""
- block = re.findall(CODE_BLOCK_RE, message.content)
-
- if not block:
- return False
- elif "#".join(repo_issue.split(" ")) in "".join([*block[0]]):
- return True
- return False
+ def remove_codeblocks(message: str) -> str:
+ """Remove any codeblock in a message."""
+ return re.sub(CODE_BLOCK_RE, "", message)
async def fetch_issues(
self,
- numbers: set,
+ number: int,
repository: str,
user: str
- ) -> t.Union[FetchIssueErrors, str, list]:
- """Retrieve issue(s) from a GitHub repository."""
- links = []
- if not numbers:
- return FetchIssueErrors.value_error
-
- if len(numbers) > MAX_REQUESTS:
- return FetchIssueErrors.max_requests
-
- for number in numbers:
- url = f"https://api.github.com/repos/{user}/{repository}/issues/{number}"
- merge_url = f"https://api.github.com/repos/{user}/{repository}/pulls/{number}/merge"
- log.trace(f"Querying GH issues API: {url}")
- async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
- json_data = await r.json()
-
- if r.status in BAD_RESPONSE:
- log.warning(f"Received response {r.status} from: {url}")
- return f"[{str(r.status)}] #{number} {BAD_RESPONSE.get(r.status)}"
-
- # The initial API request is made to the issues API endpoint, which will return information
- # if the issue or PR is present. However, the scope of information returned for PRs differs
- # from issues: if the 'issues' key is present in the response then we can pull the data we
- # need from the initial API call.
- if "issues" in json_data.get("html_url"):
- if json_data.get("state") == "open":
- icon_url = Emojis.issue
- else:
- icon_url = Emojis.issue_closed
-
- # If the 'issues' key is not contained in the API response and there is no error code, then
- # we know that a PR has been requested and a call to the pulls API endpoint is necessary
- # to get the desired information for the PR.
+ ) -> t.Union[IssueState, FetchError]:
+ """
+ Retrieve an issue from a GitHub repository.
+
+ Returns IssueState on success, FetchError on failure.
+ """
+ url = ISSUE_ENDPOINT.format(user=user, repository=repository, number=number)
+ pulls_url = PR_ENDPOINT.format(user=user, repository=repository, number=number)
+ log.trace(f"Querying GH issues API: {url}")
+
+ async with self.bot.http_session.get(url, headers=REQUEST_HEADERS) as r:
+ json_data = await r.json()
+
+ if r.status == 403:
+ if r.headers.get("X-RateLimit-Remaining") == "0":
+ log.info(f"Ratelimit reached while fetching {url}")
+ return FetchError(403, "Ratelimit reached, please retry in a few minutes.")
+ return FetchError(403, "Cannot access issue.")
+ elif r.status in (404, 410):
+ return FetchError(r.status, "Issue not found.")
+ elif r.status != 200:
+ return FetchError(r.status, "Error while fetching issue.")
+
+ # The initial API request is made to the issues API endpoint, which will return information
+ # if the issue or PR is present. However, the scope of information returned for PRs differs
+ # from issues: if the 'issues' key is present in the response then we can pull the data we
+ # need from the initial API call.
+ if "issues" in json_data["html_url"]:
+ if json_data.get("state") == "open":
+ emoji = Emojis.issue
else:
- log.trace(f"PR provided, querying GH pulls API for additional information: {merge_url}")
- async with self.bot.http_session.get(merge_url) as m:
- if json_data.get("state") == "open":
- icon_url = Emojis.pull_request
- # When the status is 204 this means that the state of the PR is merged
- elif m.status == 204:
- icon_url = Emojis.merge
- else:
- icon_url = Emojis.pull_request_closed
+ emoji = Emojis.issue_closed
+
+ # If the 'issues' key is not contained in the API response and there is no error code, then
+ # we know that a PR has been requested and a call to the pulls API endpoint is necessary
+ # to get the desired information for the PR.
+ else:
+ log.trace(f"PR provided, querying GH pulls API for additional information: {pulls_url}")
+ async with self.bot.http_session.get(pulls_url) as p:
+ pull_data = await p.json()
+ if pull_data["draft"]:
+ emoji = Emojis.pull_request_draft
+ elif pull_data["state"] == "open":
+ emoji = Emojis.pull_request
+ # When 'merged_at' is not None, this means that the state of the PR is merged
+ elif pull_data["merged_at"] is not None:
+ emoji = Emojis.merge
+ else:
+ emoji = Emojis.pull_request_closed
- issue_url = json_data.get("html_url")
- links.append([icon_url, f"[{repository}] #{number} {json_data.get('title')}", issue_url])
+ issue_url = json_data.get("html_url")
- return links
+ return IssueState(repository, number, issue_url, json_data.get('title', ''), emoji)
@staticmethod
- def get_embed(result: list, user: str = "python-discord", repository: str = "") -> discord.Embed:
- """Get Response Embed."""
- description_list = ["{0} [{1}]({2})".format(*link) for link in result]
+ def format_embed(
+ results: t.List[t.Union[IssueState, FetchError]],
+ user: str,
+ repository: t.Optional[str] = None
+ ) -> discord.Embed:
+ """Take a list of IssueState or FetchError and format a Discord embed for them."""
+ description_list = []
+
+ for result in results:
+ if isinstance(result, IssueState):
+ description_list.append(f"{result.emoji} [{result.title}]({result.url})")
+ elif isinstance(result, FetchError):
+ description_list.append(f":x: [{result.return_code}] {result.message}")
+
resp = discord.Embed(
colour=Colours.bright_green,
description='\n'.join(description_list)
)
- resp.set_author(name="GitHub", url=f"https://github.com/{user}/{repository}")
+ embed_url = f"https://github.com/{user}/{repository}" if repository else f"https://github.com/{user}"
+ resp.set_author(name="GitHub", url=embed_url)
return resp
+ @whitelist_override(channels=WHITELISTED_CHANNELS, categories=WHITELISTED_CATEGORIES)
@commands.command(aliases=("pr",))
async def issue(
self,
@@ -150,56 +193,79 @@ class Issues(commands.Cog):
user: str = "python-discord"
) -> None:
"""Command to retrieve issue(s) from a GitHub repository."""
- if not(
- ctx.channel.category.id in WHITELISTED_CATEGORIES
- or ctx.channel.id in WHITELISTED_CHANNELS
- ):
- return
-
- result = await self.fetch_issues(set(numbers), repository, user)
+ # Remove duplicates
+ numbers = set(numbers)
- if result == FetchIssueErrors.value_error:
- await ctx.invoke(self.bot.get_command('help'), 'issue')
-
- elif result == FetchIssueErrors.max_requests:
+ if len(numbers) > MAXIMUM_ISSUES:
embed = discord.Embed(
title=random.choice(ERROR_REPLIES),
color=Colours.soft_red,
- description=f"Too many issues/PRs! (maximum of {MAX_REQUESTS})"
+ description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
)
await ctx.send(embed=embed)
+ await invoke_help_command(ctx)
- elif isinstance(result, list):
- # Issue/PR format: emoji to show if open/closed/merged, number and the title as a singular link.
- resp = self.get_embed(result, user, repository)
- await ctx.send(embed=resp)
-
- elif isinstance(result, str):
- await ctx.send(result)
+ results = [await self.fetch_issues(number, repository, user) for number in numbers]
+ await ctx.send(embed=self.format_embed(results, user, repository))
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
- """Command to retrieve issue(s) from a GitHub repository using automatic linking if matching <repo>#<issue>."""
- if not(
- message.channel.category.id in WHITELISTED_CATEGORIES
- or message.channel.id in WHITELISTED_CHANNELS_ON_MESSAGE
- ):
+ """
+ Automatic issue linking.
+
+ Listener to retrieve issue(s) from a GitHub repository using automatic linking if matching <org>/<repo>#<issue>.
+ """
+ # Ignore bots
+ if message.author.bot:
return
- message_repo_issue_map = re.findall(fr"({self.repo_regex})#(\d+)", message.content)
+ issues = [
+ FoundIssue(*match.group("org", "repo", "number"))
+ for match in AUTOMATIC_REGEX.finditer(self.remove_codeblocks(message.content))
+ ]
links = []
- if message_repo_issue_map:
- for repo_issue in message_repo_issue_map:
- if not self.check_in_block(message, " ".join(repo_issue)):
- result = await self.fetch_issues({repo_issue[1]}, repo_issue[0], "python-discord")
- if isinstance(result, list):
- links.extend(result)
+ if issues:
+ # Block this from working in DMs
+ if not message.guild:
+ await message.channel.send(
+ embed=discord.Embed(
+ title=random.choice(NEGATIVE_REPLIES),
+ description=(
+ "You can't retrieve issues from DMs. "
+ f"Try again in <#{Channels.community_bot_commands}>"
+ ),
+ colour=Colours.soft_red
+ )
+ )
+ return
+
+ log.trace(f"Found {issues = }")
+ # Remove duplicates
+ issues = set(issues)
+
+ if len(issues) > MAXIMUM_ISSUES:
+ embed = discord.Embed(
+ title=random.choice(ERROR_REPLIES),
+ color=Colours.soft_red,
+ description=f"Too many issues/PRs! (maximum of {MAXIMUM_ISSUES})"
+ )
+ await message.channel.send(embed=embed, delete_after=5)
+ return
+
+ for repo_issue in issues:
+ result = await self.fetch_issues(
+ int(repo_issue.number),
+ repo_issue.repository,
+ repo_issue.organisation or "python-discord"
+ )
+ if isinstance(result, IssueState):
+ links.append(result)
if not links:
return
- resp = self.get_embed(links, "python-discord")
+ resp = self.format_embed(links, "python-discord")
await message.channel.send(embed=resp)
diff --git a/bot/exts/evergreen/latex.py b/bot/exts/evergreen/latex.py
new file mode 100644
index 00000000..c4a8597c
--- /dev/null
+++ b/bot/exts/evergreen/latex.py
@@ -0,0 +1,94 @@
+import asyncio
+import hashlib
+import pathlib
+import re
+from concurrent.futures import ThreadPoolExecutor
+from io import BytesIO
+
+import discord
+import matplotlib.pyplot as plt
+from discord.ext import commands
+
+# configure fonts and colors for matplotlib
+plt.rcParams.update(
+ {
+ "font.size": 16,
+ "mathtext.fontset": "cm", # Computer Modern font set
+ "mathtext.rm": "serif",
+ "figure.facecolor": "36393F", # matches Discord's dark mode background color
+ "text.color": "white",
+ }
+)
+
+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
+)
+
+CACHE_DIRECTORY = pathlib.Path("_latex_cache")
+CACHE_DIRECTORY.mkdir(exist_ok=True)
+
+
+class Latex(commands.Cog):
+ """Renders latex."""
+
+ @staticmethod
+ def _render(text: str, filepath: pathlib.Path) -> BytesIO:
+ """
+ Return the rendered image if latex compiles without errors, otherwise raise a BadArgument Exception.
+
+ Saves rendered image to cache.
+ """
+ fig = plt.figure()
+ rendered_image = BytesIO()
+ fig.text(0, 1, text, horizontalalignment="left", verticalalignment="top")
+
+ try:
+ plt.savefig(rendered_image, bbox_inches="tight", dpi=600)
+ except ValueError as e:
+ raise commands.BadArgument(str(e))
+
+ rendered_image.seek(0)
+
+ with open(filepath, "wb") as f:
+ f.write(rendered_image.getbuffer())
+
+ return rendered_image
+
+ @staticmethod
+ def _prepare_input(text: str) -> str:
+ text = text.replace(r"\\", "$\n$") # matplotlib uses \n for newlines, not \\
+
+ if match := FORMATTED_CODE_REGEX.match(text):
+ return match.group("code")
+ else:
+ return text
+
+ @commands.command()
+ @commands.max_concurrency(1, commands.BucketType.guild, wait=True)
+ async def latex(self, ctx: commands.Context, *, text: str) -> None:
+ """Renders the text in latex and sends the image."""
+ text = self._prepare_input(text)
+ query_hash = hashlib.md5(text.encode()).hexdigest()
+ image_path = CACHE_DIRECTORY.joinpath(f"{query_hash}.png")
+ async with ctx.typing():
+ if image_path.exists():
+ await ctx.send(file=discord.File(image_path))
+ return
+
+ with ThreadPoolExecutor() as pool:
+ image = await asyncio.get_running_loop().run_in_executor(
+ pool, self._render, text, image_path
+ )
+
+ await ctx.send(file=discord.File(image, "latex.png"))
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Latex Cog."""
+ bot.add_cog(Latex(bot))
diff --git a/bot/exts/evergreen/ping.py b/bot/exts/evergreen/ping.py
new file mode 100644
index 00000000..07c13524
--- /dev/null
+++ b/bot/exts/evergreen/ping.py
@@ -0,0 +1,44 @@
+import arrow
+from dateutil.relativedelta import relativedelta
+from discord import Embed
+from discord.ext import commands
+
+from bot import start_time
+from bot.constants import Colours
+
+
+class Ping(commands.Cog):
+ """Get info about the bot's ping and uptime."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ @commands.command(name="ping")
+ async def ping(self, ctx: commands.Context) -> None:
+ """Ping the bot to see its latency and state."""
+ embed = Embed(
+ title=":ping_pong: Pong!",
+ colour=Colours.bright_green,
+ description=f"Gateway Latency: {round(self.bot.latency * 1000)}ms",
+ )
+
+ await ctx.send(embed=embed)
+
+ # Originally made in 70d2170a0a6594561d59c7d080c4280f1ebcd70b by lemon & gdude2002
+ @commands.command(name="uptime")
+ async def uptime(self, ctx: commands.Context) -> None:
+ """Get the current uptime of the bot."""
+ difference = relativedelta(start_time - arrow.utcnow())
+ uptime_string = start_time.shift(
+ seconds=-difference.seconds,
+ minutes=-difference.minutes,
+ hours=-difference.hours,
+ days=-difference.days
+ ).humanize()
+
+ await ctx.send(f"I started up {uptime_string}.")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Load the Ping cog."""
+ bot.add_cog(Ping(bot))
diff --git a/bot/exts/evergreen/reddit.py b/bot/exts/evergreen/reddit.py
index 49127bea..e57fa2c0 100644
--- a/bot/exts/evergreen/reddit.py
+++ b/bot/exts/evergreen/reddit.py
@@ -1,128 +1,367 @@
+import asyncio
import logging
import random
+import textwrap
+from collections import namedtuple
+from datetime import datetime, timedelta
+from typing import List, Union
-import discord
-from discord.ext import commands
-from discord.ext.commands.cooldowns import BucketType
+from aiohttp import BasicAuth, ClientError
+from discord import Colour, Embed, TextChannel
+from discord.ext.commands import Cog, Context, group, has_any_role
+from discord.ext.tasks import loop
+from discord.utils import escape_markdown, sleep_until
-from bot.utils.pagination import ImagePaginator
+from bot.bot import Bot
+from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES
+from bot.utils.converters import Subreddit
+from bot.utils.extensions import invoke_help_command
+from bot.utils.messages import sub_clyde
+from bot.utils.pagination import ImagePaginator, LinePaginator
log = logging.getLogger(__name__)
+AccessToken = namedtuple("AccessToken", ["token", "expires_at"])
-class Reddit(commands.Cog):
- """Fetches reddit posts."""
- def __init__(self, bot: commands.Bot):
+class Reddit(Cog):
+ """Track subreddit posts and show detailed statistics about them."""
+
+ HEADERS = {"User-Agent": "python3:python-discord/bot:1.0.0 (by /u/PythonDiscord)"}
+ URL = "https://www.reddit.com"
+ OAUTH_URL = "https://oauth.reddit.com"
+ MAX_RETRIES = 3
+
+ def __init__(self, bot: Bot):
self.bot = bot
- async def fetch(self, url: str) -> dict:
- """Send a get request to the reddit API and get json response."""
- session = self.bot.http_session
- params = {
- 'limit': 50
- }
- headers = {
- 'User-Agent': 'Iceman'
- }
-
- async with session.get(url=url, params=params, headers=headers) as response:
- return await response.json()
-
- @commands.command(name='reddit')
- @commands.cooldown(1, 10, BucketType.user)
- async def get_reddit(self, ctx: commands.Context, subreddit: str = 'python', sort: str = "hot") -> None:
- """
- Fetch reddit posts by using this command.
+ self.webhook = None
+ self.access_token = None
+ self.client_auth = BasicAuth(RedditConfig.client_id, RedditConfig.secret)
- Gets a post from r/python by default.
- Usage:
- --> .reddit [subreddit_name] [hot/top/new]
- """
+ bot.loop.create_task(self.init_reddit_ready())
+ self.auto_poster_loop.start()
+
+ def cog_unload(self) -> None:
+ """Stop the loop task and revoke the access token when the cog is unloaded."""
+ self.auto_poster_loop.cancel()
+ if self.access_token and self.access_token.expires_at > datetime.utcnow():
+ asyncio.create_task(self.revoke_access_token())
+
+ async def init_reddit_ready(self) -> None:
+ """Sets the reddit webhook when the cog is loaded."""
+ await self.bot.wait_until_guild_available()
+ if not self.webhook:
+ self.webhook = await self.bot.fetch_webhook(RedditConfig.webhook)
+
+ @property
+ def channel(self) -> TextChannel:
+ """Get the #reddit channel object from the bot's cache."""
+ return self.bot.get_channel(Channels.reddit)
+
+ def build_pagination_pages(self, posts: List[dict], paginate: bool) -> Union[List[tuple], str]:
+ """Build embed pages required for Paginator."""
pages = []
- sort_list = ["hot", "new", "top", "rising"]
- if sort.lower() not in sort_list:
- await ctx.send(f"Invalid sorting: {sort}\nUsing default sorting: `Hot`")
- sort = "hot"
+ first_page = ""
+ for post in posts:
+ post_page = ""
+ image_url = ""
- data = await self.fetch(f'https://www.reddit.com/r/{subreddit}/{sort}/.json')
+ data = post["data"]
- try:
- posts = data["data"]["children"]
- except KeyError:
- return await ctx.send('Subreddit not found!')
- if not posts:
- return await ctx.send('No posts available!')
+ title = textwrap.shorten(data["title"], width=50, placeholder="...")
+
+ # Normal brackets interfere with Markdown.
+ title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌")
+ link = self.URL + data["permalink"]
+
+ first_page += f"**[{title.replace('*', '')}]({link})**\n"
+
+ text = data["selftext"]
+ if text:
+ first_page += textwrap.shorten(text, width=100, placeholder="...").replace("*", "") + "\n"
+
+ ups = data["ups"]
+ comments = data["num_comments"]
+ author = data["author"]
+
+ content_type = Emojis.reddit_post_text
+ if data["is_video"] or {"youtube", "youtu.be"}.issubset(set(data["url"].split("."))):
+ # This means the content type in the post is a video.
+ content_type = f"{Emojis.reddit_post_video}"
+
+ elif data["url"].endswith(("jpg", "png", "gif")):
+ # This means the content type in the post is an image.
+ content_type = f"{Emojis.reddit_post_photo}"
+ image_url = data["url"]
- if posts[1]["data"]["over_18"] is True:
- return await ctx.send(
- "You cannot access this Subreddit as it is ment for those who "
- "are 18 years or older."
+ first_page += (
+ f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}"
+ f"\u2002{comments}\u2003{Emojis.reddit_users}{author}\n\n"
)
- embed_titles = ""
+ if paginate:
+ post_page += f"**[{title}]({link})**\n\n"
+ if text:
+ post_page += textwrap.shorten(text, width=252, placeholder="...") + "\n\n"
+ post_page += (
+ f"{content_type}\u2003{Emojis.reddit_upvote}{ups}\u2003{Emojis.reddit_comments}\u2002"
+ f"{comments}\u2003{Emojis.reddit_users}{author}"
+ )
- # Chooses k unique random elements from a population sequence or set.
- random_posts = random.sample(posts, k=5)
+ pages.append((post_page, image_url))
- # -----------------------------------------------------------
- # This code below is bound of change when the emojis are added.
+ if not paginate:
+ # Return the first summery page if pagination is not required
+ return first_page
- upvote_emoji = self.bot.get_emoji(755845219890757644)
- comment_emoji = self.bot.get_emoji(755845255001014384)
- user_emoji = self.bot.get_emoji(755845303822974997)
- text_emoji = self.bot.get_emoji(676030265910493204)
- video_emoji = self.bot.get_emoji(676030265839190047)
- image_emoji = self.bot.get_emoji(676030265734201344)
- reddit_emoji = self.bot.get_emoji(676030265734332427)
+ pages.insert(0, (first_page, "")) # Using image paginator, hence settings image url to empty string
+ return pages
- # ------------------------------------------------------------
+ async def get_access_token(self) -> None:
+ """
+ Get a Reddit API OAuth2 access token and assign it to self.access_token.
- for i, post in enumerate(random_posts, start=1):
- post_title = post["data"]["title"][0:50]
- post_url = post['data']['url']
- if post_title == "":
- post_title = "No Title."
- elif post_title == post_url:
- post_title = "Title is itself a link."
+ A token is valid for 1 hour. There will be MAX_RETRIES to get a token, after which the cog
+ will be unloaded and a ClientError raised if retrieval was still unsuccessful.
+ """
+ for i in range(1, self.MAX_RETRIES + 1):
+ response = await self.bot.http_session.post(
+ url=f"{self.URL}/api/v1/access_token",
+ headers=self.HEADERS,
+ auth=self.client_auth,
+ data={
+ "grant_type": "client_credentials",
+ "duration": "temporary"
+ }
+ )
- # ------------------------------------------------------------------
- # Embed building.
+ if response.status == 200 and response.content_type == "application/json":
+ content = await response.json()
+ expiration = int(content["expires_in"]) - 60 # Subtract 1 minute for leeway.
+ self.access_token = AccessToken(
+ token=content["access_token"],
+ expires_at=datetime.utcnow() + timedelta(seconds=expiration)
+ )
- embed_titles += f"**{i}.[{post_title}]({post_url})**\n"
- image_url = " "
- post_stats = f"{text_emoji}" # Set default content type to text.
+ log.debug(f"New token acquired; expires on UTC {self.access_token.expires_at}")
+ return
+ else:
+ log.debug(
+ f"Failed to get an access token: "
+ f"status {response.status} & content type {response.content_type}; "
+ f"retrying ({i}/{self.MAX_RETRIES})"
+ )
- if post["data"]["is_video"] is True or "youtube" in post_url.split("."):
- # This means the content type in the post is a video.
- post_stats = f"{video_emoji} "
+ await asyncio.sleep(3)
- elif post_url.endswith("jpg") or post_url.endswith("png") or post_url.endswith("gif"):
- # This means the content type in the post is an image.
- post_stats = f"{image_emoji} "
- image_url = post_url
-
- votes = f'{upvote_emoji}{post["data"]["ups"]}'
- comments = f'{comment_emoji}\u2002{ post["data"]["num_comments"]}'
- post_stats += (
- f"\u2002{votes}\u2003"
- f"{comments}"
- f'\u2003{user_emoji}\u2002{post["data"]["author"]}\n'
+ self.bot.remove_cog(self.qualified_name)
+ raise ClientError("Authentication with the Reddit API failed. Unloading the cog.")
+
+ async def revoke_access_token(self) -> None:
+ """
+ Revoke the OAuth2 access token for the Reddit API.
+
+ For security reasons, it's good practice to revoke the token when it's no longer being used.
+ """
+ response = await self.bot.http_session.post(
+ url=f"{self.URL}/api/v1/revoke_token",
+ headers=self.HEADERS,
+ auth=self.client_auth,
+ data={
+ "token": self.access_token.token,
+ "token_type_hint": "access_token"
+ }
+ )
+
+ if response.status in [200, 204] and response.content_type == "application/json":
+ self.access_token = None
+ else:
+ log.warning(f"Unable to revoke access token: status {response.status}.")
+
+ async def fetch_posts(self, route: str, *, amount: int = 25, params: dict = None) -> List[dict]:
+ """A helper method to fetch a certain amount of Reddit posts at a given route."""
+ # Reddit's JSON responses only provide 25 posts at most.
+ if not 25 >= amount > 0:
+ raise ValueError("Invalid amount of subreddit posts requested.")
+
+ # Renew the token if necessary.
+ if not self.access_token or self.access_token.expires_at < datetime.utcnow():
+ await self.get_access_token()
+
+ url = f"{self.OAUTH_URL}/{route}"
+ for _ in range(self.MAX_RETRIES):
+ response = await self.bot.http_session.get(
+ url=url,
+ headers={**self.HEADERS, "Authorization": f"bearer {self.access_token.token}"},
+ params=params
+ )
+ if response.status == 200 and response.content_type == 'application/json':
+ # Got appropriate response - process and return.
+ content = await response.json()
+ posts = content["data"]["children"]
+
+ filtered_posts = [post for post in posts if not post["data"]["over_18"]]
+
+ return filtered_posts[:amount]
+
+ await asyncio.sleep(3)
+
+ log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}")
+ return list() # Failed to get appropriate response within allowed number of retries.
+
+ async def get_top_posts(
+ self, subreddit: Subreddit, time: str = "all", amount: int = 5, paginate: bool = False
+ ) -> Union[Embed, List[tuple]]:
+ """
+ Get the top amount of posts for a given subreddit within a specified timeframe.
+
+ A time of "all" will get posts from all time, "day" will get top daily posts and "week" will get the top
+ weekly posts.
+
+ The amount should be between 0 and 25 as Reddit's JSON requests only provide 25 posts at most.
+ """
+ embed = Embed()
+
+ posts = await self.fetch_posts(
+ route=f"{subreddit}/top",
+ amount=amount,
+ params={"t": time}
+ )
+ if not posts:
+ embed.title = random.choice(ERROR_REPLIES)
+ embed.colour = Colour.red()
+ embed.description = (
+ "Sorry! We couldn't find any SFW posts from that subreddit. "
+ "If this problem persists, please let us know."
)
- embed_titles += f"{post_stats}\n"
- page_text = f"**[{post_title}]({post_url})**\n{post_stats}\n{post['data']['selftext'][0:200]}"
- embed = discord.Embed()
- page_tuple = (page_text, image_url)
- pages.append(page_tuple)
+ return embed
+
+ if paginate:
+ return self.build_pagination_pages(posts, paginate=True)
+
+ # Use only starting summary page for #reddit channel posts.
+ embed.description = self.build_pagination_pages(posts, paginate=False)
+ embed.colour = Colour.blurple()
+ return embed
+
+ @loop()
+ async def auto_poster_loop(self) -> None:
+ """Post the top 5 posts daily, and the top 5 posts weekly."""
+ # once d.py get support for `time` parameter in loop decorator,
+ # this can be removed and the loop can use the `time=datetime.time.min` parameter
+ now = datetime.utcnow()
+ tomorrow = now + timedelta(days=1)
+ midnight_tomorrow = tomorrow.replace(hour=0, minute=0, second=0)
+
+ await sleep_until(midnight_tomorrow)
+
+ await self.bot.wait_until_guild_available()
+ if not self.webhook:
+ await self.bot.fetch_webhook(RedditConfig.webhook)
+
+ if datetime.utcnow().weekday() == 0:
+ await self.top_weekly_posts()
+ # if it's a monday send the top weekly posts
+
+ for subreddit in RedditConfig.subreddits:
+ top_posts = await self.get_top_posts(subreddit=subreddit, time="day")
+ username = sub_clyde(f"{subreddit} Top Daily Posts")
+ message = await self.webhook.send(username=username, embed=top_posts, wait=True)
+
+ if message.channel.is_news():
+ await message.publish()
+
+ async def top_weekly_posts(self) -> None:
+ """Post a summary of the top posts."""
+ for subreddit in RedditConfig.subreddits:
+ # Send and pin the new weekly posts.
+ top_posts = await self.get_top_posts(subreddit=subreddit, time="week")
+ username = sub_clyde(f"{subreddit} Top Weekly Posts")
+ message = await self.webhook.send(wait=True, username=username, embed=top_posts)
- # ------------------------------------------------------------------
+ if subreddit.lower() == "r/python":
+ if not self.channel:
+ log.warning("Failed to get #reddit channel to remove pins in the weekly loop.")
+ return
+
+ # Remove the oldest pins so that only 12 remain at most.
+ pins = await self.channel.pins()
+
+ while len(pins) >= 12:
+ await pins[-1].unpin()
+ del pins[-1]
+
+ await message.pin()
+
+ if message.channel.is_news():
+ await message.publish()
+
+ @group(name="reddit", invoke_without_command=True)
+ async def reddit_group(self, ctx: Context) -> None:
+ """View the top posts from various subreddits."""
+ await invoke_help_command(ctx)
+
+ @reddit_group.command(name="top")
+ async def top_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of all time from a given subreddit."""
+ async with ctx.typing():
+ pages = await self.get_top_posts(subreddit=subreddit, time="all", paginate=True)
+
+ await ctx.send(f"Here are the top {subreddit} posts of all time!")
+ embed = Embed(
+ color=Colour.blurple()
+ )
- pages.insert(0, (embed_titles, " "))
- embed.set_author(name=f"r/{posts[0]['data']['subreddit']} - {sort}", icon_url=reddit_emoji.url)
await ImagePaginator.paginate(pages, ctx, embed)
+ @reddit_group.command(name="daily")
+ async def daily_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of today from a given subreddit."""
+ async with ctx.typing():
+ pages = await self.get_top_posts(subreddit=subreddit, time="day", paginate=True)
+
+ await ctx.send(f"Here are today's top {subreddit} posts!")
+ embed = Embed(
+ color=Colour.blurple()
+ )
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @reddit_group.command(name="weekly")
+ async def weekly_command(self, ctx: Context, subreddit: Subreddit = "r/Python") -> None:
+ """Send the top posts of this week from a given subreddit."""
+ async with ctx.typing():
+ pages = await self.get_top_posts(subreddit=subreddit, time="week", paginate=True)
+
+ await ctx.send(f"Here are this week's top {subreddit} posts!")
+ embed = Embed(
+ color=Colour.blurple()
+ )
+
+ await ImagePaginator.paginate(pages, ctx, embed)
+
+ @has_any_role(*STAFF_ROLES)
+ @reddit_group.command(name="subreddits", aliases=("subs",))
+ async def subreddits_command(self, ctx: Context) -> None:
+ """Send a paginated embed of all the subreddits we're relaying."""
+ embed = Embed()
+ embed.title = "Relayed subreddits."
+ embed.colour = Colour.blurple()
+
+ await LinePaginator.paginate(
+ RedditConfig.subreddits,
+ ctx, embed,
+ footer_text="Use the reddit commands along with these to view their posts.",
+ empty=False,
+ max_lines=15
+ )
+
-def setup(bot: commands.Bot) -> None:
- """Load the Cog."""
+def setup(bot: Bot) -> None:
+ """Load the Reddit cog."""
+ if not RedditConfig.secret or not RedditConfig.client_id:
+ log.error("Credentials not provided, cog not loaded.")
+ return
bot.add_cog(Reddit(bot))
diff --git a/bot/exts/evergreen/timed.py b/bot/exts/evergreen/timed.py
new file mode 100644
index 00000000..5f177fd6
--- /dev/null
+++ b/bot/exts/evergreen/timed.py
@@ -0,0 +1,46 @@
+from copy import copy
+from time import perf_counter
+
+from discord import Message
+from discord.ext import commands
+
+
+class TimedCommands(commands.Cog):
+ """Time the command execution of a command."""
+
+ @staticmethod
+ async def create_execution_context(ctx: commands.Context, command: str) -> commands.Context:
+ """Get a new execution context for a command."""
+ msg: Message = copy(ctx.message)
+ msg.content = f"{ctx.prefix}{command}"
+
+ return await ctx.bot.get_context(msg)
+
+ @commands.command(name="timed", aliases=["time", "t"])
+ async def timed(self, ctx: commands.Context, *, command: str) -> None:
+ """Time the command execution of a command."""
+ new_ctx = await self.create_execution_context(ctx, command)
+
+ ctx.subcontext = new_ctx
+
+ if not ctx.subcontext.command:
+ help_command = f"{ctx.prefix}help"
+ error = f"The command you are trying to time doesn't exist. Use `{help_command}` for a list of commands."
+
+ await ctx.send(error)
+ return
+
+ if new_ctx.command.qualified_name == "timed":
+ await ctx.send("You are not allowed to time the execution of the `timed` command.")
+ return
+
+ t_start = perf_counter()
+ await new_ctx.command.invoke(new_ctx)
+ t_end = perf_counter()
+
+ await ctx.send(f"Command execution for `{new_ctx.command}` finished in {(t_end - t_start):.4f} seconds.")
+
+
+def setup(bot: commands.Bot) -> None:
+ """Cog load."""
+ bot.add_cog(TimedCommands(bot))
diff --git a/bot/exts/evergreen/uptime.py b/bot/exts/evergreen/uptime.py
deleted file mode 100644
index a9ad9dfb..00000000
--- a/bot/exts/evergreen/uptime.py
+++ /dev/null
@@ -1,33 +0,0 @@
-import logging
-
-import arrow
-from dateutil.relativedelta import relativedelta
-from discord.ext import commands
-
-from bot import start_time
-
-log = logging.getLogger(__name__)
-
-
-class Uptime(commands.Cog):
- """A cog for posting the bot's uptime."""
-
- def __init__(self, bot: commands.Bot):
- self.bot = bot
-
- @commands.command(name="uptime")
- async def uptime(self, ctx: commands.Context) -> None:
- """Responds with the uptime of the bot."""
- difference = relativedelta(start_time - arrow.utcnow())
- uptime_string = start_time.shift(
- seconds=-difference.seconds,
- minutes=-difference.minutes,
- hours=-difference.hours,
- days=-difference.days
- ).humanize()
- await ctx.send(f"I started up {uptime_string}.")
-
-
-def setup(bot: commands.Bot) -> None:
- """Uptime Cog load."""
- bot.add_cog(Uptime(bot))
diff --git a/bot/exts/evergreen/wolfram.py b/bot/exts/evergreen/wolfram.py
index 437d9e1a..14ec1041 100644
--- a/bot/exts/evergreen/wolfram.py
+++ b/bot/exts/evergreen/wolfram.py
@@ -62,7 +62,8 @@ def custom_cooldown(*ignore: List[int]) -> Callable:
# if the invoked command is help we don't want to increase the ratelimits since it's not actually
# invoking the command/making a request, so instead just check if the user/guild are on cooldown.
guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown
- if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored
+ # check the message is in a guild, and check user bucket if user is not ignored
+ if ctx.guild and not any(r.id in ignore for r in ctx.author.roles):
return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0
return guild_cooldown
diff --git a/bot/exts/halloween/candy_collection.py b/bot/exts/halloween/candy_collection.py
index 0cb37ecd..40e21f40 100644
--- a/bot/exts/halloween/candy_collection.py
+++ b/bot/exts/halloween/candy_collection.py
@@ -47,6 +47,9 @@ class CandyCollection(commands.Cog):
@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
"""Randomly adds candy or skull reaction to non-bot messages in the Event channel."""
+ # Ignore messages in DMs
+ if not message.guild:
+ return
# make sure its a human message
if message.author.bot:
return
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/internal_eval/__init__.py b/bot/exts/internal_eval/__init__.py
new file mode 100644
index 00000000..695fa74d
--- /dev/null
+++ b/bot/exts/internal_eval/__init__.py
@@ -0,0 +1,10 @@
+from bot.bot import Bot
+
+
+def setup(bot: Bot) -> None:
+ """Set up the Internal Eval extension."""
+ # Import the Cog at runtime to prevent side effects like defining
+ # RedisCache instances too early.
+ from ._internal_eval import InternalEval
+
+ bot.add_cog(InternalEval(bot))
diff --git a/bot/exts/internal_eval/_helpers.py b/bot/exts/internal_eval/_helpers.py
new file mode 100644
index 00000000..3a50b9f3
--- /dev/null
+++ b/bot/exts/internal_eval/_helpers.py
@@ -0,0 +1,249 @@
+import ast
+import collections
+import contextlib
+import functools
+import inspect
+import io
+import logging
+import sys
+import traceback
+import types
+import typing
+
+
+log = logging.getLogger(__name__)
+
+# A type alias to annotate the tuples returned from `sys.exc_info()`
+ExcInfo = typing.Tuple[typing.Type[Exception], Exception, types.TracebackType]
+Namespace = typing.Dict[str, typing.Any]
+
+# This will be used as an coroutine function wrapper for the code
+# to be evaluated. The wrapper contains one `pass` statement which
+# will be replaced with `ast` with the code that we want to have
+# evaluated.
+# The function redirects output and captures exceptions that were
+# raised in the code we evaluate. The latter is used to provide a
+# meaningful traceback to the end user.
+EVAL_WRAPPER = """
+async def _eval_wrapper_function():
+ try:
+ with contextlib.redirect_stdout(_eval_context.stdout):
+ pass
+ if '_value_last_expression' in locals():
+ if inspect.isawaitable(_value_last_expression):
+ _value_last_expression = await _value_last_expression
+ _eval_context._value_last_expression = _value_last_expression
+ else:
+ _eval_context._value_last_expression = None
+ except Exception:
+ _eval_context.exc_info = sys.exc_info()
+ finally:
+ _eval_context.locals = locals()
+_eval_context.function = _eval_wrapper_function
+"""
+INTERNAL_EVAL_FRAMENAME = "<internal eval>"
+EVAL_WRAPPER_FUNCTION_FRAMENAME = "_eval_wrapper_function"
+
+
+def format_internal_eval_exception(exc_info: ExcInfo, code: str) -> str:
+ """Format an exception caught while evaluation code by inserting lines."""
+ exc_type, exc_value, tb = exc_info
+ stack_summary = traceback.StackSummary.extract(traceback.walk_tb(tb))
+ code = code.split("\n")
+
+ output = ["Traceback (most recent call last):"]
+ for frame in stack_summary:
+ if frame.filename == INTERNAL_EVAL_FRAMENAME:
+ line = code[frame.lineno - 1].lstrip()
+
+ if frame.name == EVAL_WRAPPER_FUNCTION_FRAMENAME:
+ name = INTERNAL_EVAL_FRAMENAME
+ else:
+ name = frame.name
+ else:
+ line = frame.line
+ name = frame.name
+
+ output.append(
+ f' File "{frame.filename}", line {frame.lineno}, in {name}\n'
+ f" {line}"
+ )
+
+ output.extend(traceback.format_exception_only(exc_type, exc_value))
+ return "\n".join(output)
+
+
+class EvalContext:
+ """
+ Represents the current `internal eval` context.
+
+ The context remembers names set during earlier runs of `internal eval`. To
+ clear the context, use the `.internal clear` command.
+ """
+
+ def __init__(self, context_vars: Namespace, local_vars: Namespace) -> None:
+ self._locals = dict(local_vars)
+ self.context_vars = dict(context_vars)
+
+ self.stdout = io.StringIO()
+ self._value_last_expression = None
+ self.exc_info = None
+ self.code = ""
+ self.function = None
+ self.eval_tree = None
+
+ @property
+ def dependencies(self) -> typing.Dict[str, typing.Any]:
+ """
+ Return a mapping of the dependencies for the wrapper function.
+
+ By using a property descriptor, the mapping can't be accidentally
+ mutated during evaluation. This ensures the dependencies are always
+ available.
+ """
+ return {
+ "print": functools.partial(print, file=self.stdout),
+ "contextlib": contextlib,
+ "inspect": inspect,
+ "sys": sys,
+ "_eval_context": self,
+ "_": self._value_last_expression,
+ }
+
+ @property
+ def locals(self) -> typing.Dict[str, typing.Any]:
+ """Return a mapping of names->values needed for evaluation."""
+ return {**collections.ChainMap(self.dependencies, self.context_vars, self._locals)}
+
+ @locals.setter
+ def locals(self, locals_: typing.Dict[str, typing.Any]) -> None:
+ """Update the contextual mapping of names to values."""
+ log.trace(f"Updating {self._locals} with {locals_}")
+ self._locals.update(locals_)
+
+ def prepare_eval(self, code: str) -> typing.Optional[str]:
+ """Prepare an evaluation by processing the code and setting up the context."""
+ self.code = code
+
+ if not self.code:
+ log.debug("No code was attached to the evaluation command")
+ return "[No code detected]"
+
+ try:
+ code_tree = ast.parse(code, filename=INTERNAL_EVAL_FRAMENAME)
+ except SyntaxError:
+ log.debug("Got a SyntaxError while parsing the eval code")
+ return "".join(traceback.format_exception(*sys.exc_info(), limit=0))
+
+ log.trace("Parsing the AST to see if there's a trailing expression we need to capture")
+ code_tree = CaptureLastExpression(code_tree).capture()
+
+ log.trace("Wrapping the AST in the AST of the wrapper coroutine")
+ eval_tree = WrapEvalCodeTree(code_tree).wrap()
+
+ self.eval_tree = eval_tree
+ return None
+
+ async def run_eval(self) -> Namespace:
+ """Run the evaluation and return the updated locals."""
+ log.trace("Compiling the AST to bytecode using `exec` mode")
+ compiled_code = compile(self.eval_tree, filename=INTERNAL_EVAL_FRAMENAME, mode="exec")
+
+ log.trace("Executing the compiled code with the desired namespace environment")
+ exec(compiled_code, self.locals) # noqa: B102,S102
+
+ log.trace("Awaiting the created evaluation wrapper coroutine.")
+ await self.function()
+
+ log.trace("Returning the updated captured locals.")
+ return self._locals
+
+ def format_output(self) -> str:
+ """Format the output of the most recent evaluation."""
+ output = []
+
+ log.trace(f"Getting output from stdout `{id(self.stdout)}`")
+ stdout_text = self.stdout.getvalue()
+ if stdout_text:
+ log.trace("Appending output captured from stdout/print")
+ output.append(stdout_text)
+
+ if self._value_last_expression is not None:
+ log.trace("Appending the output of a captured trialing expression")
+ output.append(f"[Captured] {self._value_last_expression!r}")
+
+ if self.exc_info:
+ log.trace("Appending exception information")
+ output.append(format_internal_eval_exception(self.exc_info, self.code))
+
+ log.trace(f"Generated output: {output!r}")
+ return "\n".join(output) or "[No output]"
+
+
+class WrapEvalCodeTree(ast.NodeTransformer):
+ """Wraps the AST of eval code with the wrapper function."""
+
+ def __init__(self, eval_code_tree: ast.AST, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.eval_code_tree = eval_code_tree
+
+ # To avoid mutable aliasing, parse the WRAPPER_FUNC for each wrapping
+ self.wrapper = ast.parse(EVAL_WRAPPER, filename=INTERNAL_EVAL_FRAMENAME)
+
+ def wrap(self) -> ast.AST:
+ """Wrap the tree of the code by the tree of the wrapper function."""
+ new_tree = self.visit(self.wrapper)
+ return ast.fix_missing_locations(new_tree)
+
+ def visit_Pass(self, node: ast.Pass) -> typing.List[ast.AST]: # noqa: N802
+ """
+ Replace the `_ast.Pass` node in the wrapper function by the eval AST.
+
+ This method works on the assumption that there's a single `pass`
+ statement in the wrapper function.
+ """
+ return list(ast.iter_child_nodes(self.eval_code_tree))
+
+
+class CaptureLastExpression(ast.NodeTransformer):
+ """Captures the return value from a loose expression."""
+
+ def __init__(self, tree: ast.AST, *args, **kwargs) -> None:
+ super().__init__(*args, **kwargs)
+ self.tree = tree
+ self.last_node = list(ast.iter_child_nodes(tree))[-1]
+
+ def visit_Expr(self, node: ast.Expr) -> typing.Union[ast.Expr, ast.Assign]: # noqa: N802
+ """
+ Replace the Expr node that is last child node of Module with an assignment.
+
+ We use an assignment to capture the value of the last node, if it's a loose
+ Expr node. Normally, the value of an Expr node is lost, meaning we don't get
+ the output of such a last "loose" expression. By assigning it a name, we can
+ retrieve it for our output.
+ """
+ if node is not self.last_node:
+ return node
+
+ log.trace("Found a trailing last expression in the evaluation code")
+
+ log.trace("Creating assignment statement with trailing expression as the right-hand side")
+ right_hand_side = list(ast.iter_child_nodes(node))[0]
+
+ assignment = ast.Assign(
+ targets=[ast.Name(id='_value_last_expression', ctx=ast.Store())],
+ value=right_hand_side,
+ lineno=node.lineno,
+ col_offset=0,
+ )
+ ast.fix_missing_locations(assignment)
+ return assignment
+
+ def capture(self) -> ast.AST:
+ """Capture the value of the last expression with an assignment."""
+ if not isinstance(self.last_node, ast.Expr):
+ # We only have to replace a node if the very last node is an Expr node
+ return self.tree
+
+ new_tree = self.visit(self.tree)
+ return ast.fix_missing_locations(new_tree)
diff --git a/bot/exts/internal_eval/_internal_eval.py b/bot/exts/internal_eval/_internal_eval.py
new file mode 100644
index 00000000..757a2a1e
--- /dev/null
+++ b/bot/exts/internal_eval/_internal_eval.py
@@ -0,0 +1,176 @@
+import logging
+import re
+import textwrap
+import typing
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Roles
+from bot.utils.decorators import with_role
+from bot.utils.extensions import invoke_help_command
+from ._helpers import EvalContext
+
+__all__ = ["InternalEval"]
+
+log = logging.getLogger(__name__)
+
+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
+)
+
+RAW_CODE_REGEX = re.compile(
+ r"^(?:[ \t]*\n)*" # any blank (empty or tabs/spaces only) lines before the code
+ r"(?P<code>.*?)" # extract all the rest as code
+ r"\s*$", # any trailing whitespace until the end of the string
+ re.DOTALL # "." also matches newlines
+)
+
+
+class InternalEval(commands.Cog):
+ """Top secret code evaluation for admins and owners."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.locals = {}
+
+ @staticmethod
+ def shorten_output(
+ output: str,
+ max_length: int = 1900,
+ placeholder: str = "\n[output truncated]"
+ ) -> str:
+ """
+ Shorten the `output` so it's shorter than `max_length`.
+
+ There are three tactics for this, tried in the following order:
+ - Shorten the output on a line-by-line basis
+ - Shorten the output on any whitespace character
+ - Shorten the output solely on character count
+ """
+ max_length = max_length - len(placeholder)
+
+ shortened_output = []
+ char_count = 0
+ for line in output.split("\n"):
+ if char_count + len(line) > max_length:
+ break
+ shortened_output.append(line)
+ char_count += len(line) + 1 # account for (possible) line ending
+
+ if shortened_output:
+ shortened_output.append(placeholder)
+ return "\n".join(shortened_output)
+
+ shortened_output = textwrap.shorten(output, width=max_length, placeholder=placeholder)
+
+ if shortened_output.strip() == placeholder.strip():
+ # `textwrap` was unable to find whitespace to shorten on, so it has
+ # reduced the output to just the placeholder. Let's shorten based on
+ # characters instead.
+ shortened_output = output[:max_length] + placeholder
+
+ return shortened_output
+
+ async def _upload_output(self, output: str) -> typing.Optional[str]:
+ """Upload `internal eval` output to our pastebin and return the url."""
+ try:
+ async with self.bot.http_session.post(
+ "https://paste.pythondiscord.com/documents", data=output, raise_for_status=True
+ ) as resp:
+ data = await resp.json()
+
+ if "key" in data:
+ return f"https://paste.pythondiscord.com/{data['key']}"
+ except Exception:
+ # 400 (Bad Request) means there are too many characters
+ log.exception("Failed to upload `internal eval` output to paste service!")
+
+ async def _send_output(self, ctx: commands.Context, output: str) -> None:
+ """Send the `internal eval` output to the command invocation context."""
+ upload_message = ""
+ if len(output) >= 1980:
+ # The output is too long, let's truncate it for in-channel output and
+ # upload the complete output to the paste service.
+ url = await self._upload_output(output)
+
+ if url:
+ upload_message = f"\nFull output here: {url}"
+ else:
+ upload_message = "\n:warning: Failed to upload full output!"
+
+ output = self.shorten_output(output)
+
+ await ctx.send(f"```py\n{output}\n```{upload_message}")
+
+ async def _eval(self, ctx: commands.Context, code: str) -> None:
+ """Evaluate the `code` in the current evaluation context."""
+ context_vars = {
+ "message": ctx.message,
+ "author": ctx.message.author,
+ "channel": ctx.channel,
+ "guild": ctx.guild,
+ "ctx": ctx,
+ "self": self,
+ "bot": self.bot,
+ "discord": discord,
+ }
+
+ eval_context = EvalContext(context_vars, self.locals)
+
+ log.trace("Preparing the evaluation by parsing the AST of the code")
+ error = eval_context.prepare_eval(code)
+
+ if error:
+ log.trace("The code can't be evaluated due to an error")
+ await ctx.send(f"```py\n{error}\n```")
+ return
+
+ log.trace("Evaluate the AST we've generated for the evaluation")
+ new_locals = await eval_context.run_eval()
+
+ log.trace("Updating locals with those set during evaluation")
+ self.locals.update(new_locals)
+
+ log.trace("Sending the formatted output back to the context")
+ await self._send_output(ctx, eval_context.format_output())
+
+ @commands.group(name='internal', aliases=('int',))
+ @with_role(Roles.admin)
+ async def internal_group(self, ctx: commands.Context) -> None:
+ """Internal commands. Top secret!"""
+ if not ctx.invoked_subcommand:
+ await invoke_help_command(ctx)
+
+ @internal_group.command(name='eval', aliases=('e',))
+ @with_role(Roles.admin)
+ async def eval(self, ctx: commands.Context, *, code: str) -> None:
+ """Run eval in a REPL-like format."""
+ if match := list(FORMATTED_CODE_REGEX.finditer(code)):
+ blocks = [block for block in match if block.group("block")]
+
+ if len(blocks) > 1:
+ code = '\n'.join(block.group("code") for block in blocks)
+ else:
+ match = match[0] if len(blocks) == 0 else blocks[0]
+ code, block, lang, delim = match.group("code", "block", "lang", "delim")
+
+ else:
+ code = RAW_CODE_REGEX.fullmatch(code).group("code")
+
+ code = textwrap.dedent(code)
+ await self._eval(ctx, code)
+
+ @internal_group.command(name='reset', aliases=("clear", "exit", "r", "c"))
+ @with_role(Roles.admin)
+ async def reset(self, ctx: commands.Context) -> None:
+ """Reset the context and locals of the eval session."""
+ self.locals = {}
+ await ctx.send("The evaluation context was reset.")
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/easter/april_fools_vids.json b/bot/resources/easter/april_fools_vids.json
index d8c1efa1..e1e8c70a 100644
--- a/bot/resources/easter/april_fools_vids.json
+++ b/bot/resources/easter/april_fools_vids.json
@@ -1,121 +1,130 @@
-{
- "google": [
- {
- "title": "Introducing Bad Joke Detector",
- "link": "https://youtu.be/OYcv406J_J4"
- },
- {
- "title": "Introducing Google Cloud Hummus API - Find your Hummus!",
- "link": "https://youtu.be/0_5X6N6DHyk"
- },
- {
- "title": "Introducing Google Play for Pets",
- "link": "https://youtu.be/UmJ2NBHXTqo"
- },
- {
- "title": "Haptic Helpers: bringing you to your senses",
- "link": "https://youtu.be/3MA6_21nka8"
- },
- {
- "title": "Introducing Google Wind",
- "link": "https://youtu.be/QAwL0O5nXe0"
- },
- {
- "title": "Experience YouTube in #SnoopaVision",
- "link": "https://youtu.be/DPEJB-FCItk"
- },
- {
- "title": "Introducing the self-driving bicycle in the Netherlands",
- "link": "https://youtu.be/LSZPNwZex9s"
- },
- {
- "title": "Android Developer Story: The Guardian goes galactic with Android and Google Play",
- "link": "https://youtu.be/dFrgNiweQDk"
- },
- {
- "title": "Introducing new delivery technology from Google Express",
- "link": "https://youtu.be/F0F6SnbqUcE"
- },
- {
- "title": "Google Cardboard Plastic",
- "link": "https://youtu.be/VkOuShXpoKc"
- },
- {
- "title": "Google Photos: Search your photos by emoji",
- "link": "https://youtu.be/HQtGFBbwKEk"
- },
- {
- "title": "Introducing Google Actual Cloud Platform",
- "link": "https://youtu.be/Cp10_PygJ4o"
- },
- {
- "title": "Introducing Dial-Up mode",
- "link": "https://youtu.be/XTTtkisylQw"
- },
- {
- "title": "Smartbox by Inbox: the mailbox of tomorrow, today",
- "link": "https://youtu.be/hydLZJXG3Tk"
- },
- {
- "title": "Introducing Coffee to the Home",
- "link": "https://youtu.be/U2JBFlW--UU"
- },
- {
- "title": "Chrome for Android and iOS: Emojify the Web",
- "link": "https://youtu.be/G3NXNnoGr3Y"
- },
- {
- "title": "Google Maps: Pokémon Challenge",
- "link": "https://youtu.be/4YMD6xELI_k"
- },
- {
- "title": "Introducing Google Fiber to the Pole",
- "link": "https://youtu.be/qcgWRpQP6ds"
- },
- {
- "title": "Introducing Gmail Blue",
- "link": "https://youtu.be/Zr4JwPb99qU"
- },
- {
- "title": "Introducing Google Nose",
- "link": "https://youtu.be/VFbYadm_mrw"
- },
- {
- "title": "Explore Treasure Mode with Google Maps",
- "link": "https://youtu.be/_qFFHC0eIUc"
- },
- {
- "title": "YouTube's ready to select a winner",
- "link": "https://youtu.be/H542nLTTbu0"
- },
- {
- "title": "A word about Gmail Tap",
- "link": "https://youtu.be/Je7Xq9tdCJc"
- },
- {
- "title": "Introducing the Google Fiber Bar",
- "link": "https://youtu.be/re0VRK6ouwI"
- },
- {
- "title": "Introducing Gmail Tap",
- "link": "https://youtu.be/1KhZKNZO8mQ"
- },
- {
- "title": "Chrome Multitask Mode",
- "link": "https://youtu.be/UiLSiqyDf4Y"
- },
- {
- "title": "Google Maps 8-bit for NES",
- "link": "https://youtu.be/rznYifPHxDg"
- },
- {
- "title": "Being a Google Autocompleter",
- "link": "https://youtu.be/blB_X38YSxQ"
- },
- {
- "title": "Introducing Gmail Motion",
- "link": "https://youtu.be/Bu927_ul_X0"
- }
- ]
-
-}
+[
+ {
+ "url": "https://youtu.be/OYcv406J_J4",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/0_5X6N6DHyk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/UmJ2NBHXTqo",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/3MA6_21nka8",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/QAwL0O5nXe0",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/DPEJB-FCItk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/LSZPNwZex9s",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/dFrgNiweQDk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/F0F6SnbqUcE",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/VkOuShXpoKc",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/HQtGFBbwKEk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Cp10_PygJ4o",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/XTTtkisylQw",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/hydLZJXG3Tk",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/U2JBFlW--UU",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/G3NXNnoGr3Y",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/4YMD6xELI_k",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/qcgWRpQP6ds",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Zr4JwPb99qU",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/VFbYadm_mrw",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/_qFFHC0eIUc",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/H542nLTTbu0",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Je7Xq9tdCJc",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/re0VRK6ouwI",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/1KhZKNZO8mQ",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/UiLSiqyDf4Y",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/rznYifPHxDg",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/blB_X38YSxQ",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/Bu927_ul_X0",
+ "channel": "google"
+ },
+ {
+ "url": "https://youtu.be/smM-Wdk2RLQ",
+ "channel": "nvidia"
+ },
+ {
+ "url": "https://youtu.be/IlCx5gjAmqI",
+ "channel": "razer"
+ },
+ {
+ "url": "https://youtu.be/j8UJE7DoyJ8",
+ "channel": "razer"
+ }
+]
diff --git a/bot/resources/easter/easter_riddle.json b/bot/resources/easter/easter_riddle.json
index e93f6dad..f7eb63d8 100644
--- a/bot/resources/easter/easter_riddle.json
+++ b/bot/resources/easter/easter_riddle.json
@@ -64,14 +64,6 @@
"correct_answer": "A chocolate one"
},
{
- "question": "Where does the Easter Bunny get his eggs?",
- "riddles": [
- "Not a bush or tree",
- "Emoji for a body part"
- ],
- "correct_answer": "Eggplants"
- },
- {
"question": "Why did the Easter Bunny have to fire the duck?",
"riddles": [
"Quack",
diff --git a/bot/resources/evergreen/py_topics.yaml b/bot/resources/evergreen/py_topics.yaml
index f3b2eaa3..6b7e0206 100644
--- a/bot/resources/evergreen/py_topics.yaml
+++ b/bot/resources/evergreen/py_topics.yaml
@@ -69,7 +69,11 @@
# game-development
660625198390837248:
- -
+ - What is your favorite game mechanic?
+ - What is your favorite framework and why?
+ - What games do you know that were written in Python?
+ - What books or tutorials would you recommend for game-development beginners?
+ - What made you start developing games?
# microcontrollers
545603026732318730:
diff --git a/bot/resources/evergreen/starter.yaml b/bot/resources/evergreen/starter.yaml
index 949220f9..6b0de0ef 100644
--- a/bot/resources/evergreen/starter.yaml
+++ b/bot/resources/evergreen/starter.yaml
@@ -6,7 +6,6 @@
- "What is better: Milk, Dark or White chocolate?"
- What is your favourite holiday?
- If you could have any superpower, what would it be?
-- Name one thing you like about a person to your right.
- If you could be anyone else for one day, who would it be?
- What Easter tradition do you enjoy most?
- What is the best gift you've been given?
@@ -31,3 +30,22 @@
- What is your favorite TV show?
- What is your favorite media genre?
- How many years have you spent coding?
+- What book do you highly recommend everyone to read?
+- What websites do you use daily to keep yourself up to date with the industry?
+- What made you want to join this Discord server?
+- How are you?
+- What is the best advice you have ever gotten in regards to programming/software?
+- What is the most satisfying thing you've done in your life?
+- Who is your favorite music composer/producer/singer?
+- What is your favorite song?
+- What is your favorite video game?
+- What are your hobbies other than programming?
+- Who is your favorite Writer?
+- What is your favorite movie?
+- What is your favorite sport?
+- What is your favorite fruit?
+- What is your favorite juice?
+- What is the best scenery you've ever seen?
+- What artistic talents do you have?
+- What is the tallest building you've entered?
+- What is the oldest computer you've ever used?
diff --git a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3 b/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3
deleted file mode 100644
index 495f2bd1..00000000
--- a/bot/resources/halloween/spookysounds/109710__tomlija__horror-gate.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3 b/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3
deleted file mode 100644
index 538feabc..00000000
--- a/bot/resources/halloween/spookysounds/126113__klankbeeld__laugh.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3 b/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3
deleted file mode 100644
index 17f66698..00000000
--- a/bot/resources/halloween/spookysounds/133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3 b/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3
deleted file mode 100644
index 5670657c..00000000
--- a/bot/resources/halloween/spookysounds/14570__oscillator__ghost-fx.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3 b/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3
deleted file mode 100644
index 42f9e9fd..00000000
--- a/bot/resources/halloween/spookysounds/168650__0xmusex0__doorcreak.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3 b/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3
deleted file mode 100644
index 1cdb0f4d..00000000
--- a/bot/resources/halloween/spookysounds/171078__klankbeeld__horror-scream-woman-long.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3 b/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3
deleted file mode 100644
index 89150d57..00000000
--- a/bot/resources/halloween/spookysounds/193812__geoneo0__four-voices-whispering-6.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3 b/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3
deleted file mode 100644
index b5f85f8d..00000000
--- a/bot/resources/halloween/spookysounds/237282__devilfish101__frantic-violin-screech.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3 b/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3
deleted file mode 100644
index d141f68e..00000000
--- a/bot/resources/halloween/spookysounds/249686__cylon8472__cthulhu-growl.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3 b/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3
deleted file mode 100644
index a0614b53..00000000
--- a/bot/resources/halloween/spookysounds/35716__analogchill__scream.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3 b/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3
deleted file mode 100644
index 38374316..00000000
--- a/bot/resources/halloween/spookysounds/413315__inspectorj__something-evil-approaches-a.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3 b/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3
deleted file mode 100644
index f769d9d8..00000000
--- a/bot/resources/halloween/spookysounds/60571__gabemiller74__breathofdeath.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3 b/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3
deleted file mode 100644
index 8b04f0f5..00000000
--- a/bot/resources/halloween/spookysounds/Female_Monster_Growls_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3 b/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3
deleted file mode 100644
index 964d685e..00000000
--- a/bot/resources/halloween/spookysounds/Male_Zombie_Roar_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3
deleted file mode 100644
index 9e643773..00000000
--- a/bot/resources/halloween/spookysounds/Monster_Alien_Growl_Calm_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3 b/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3
deleted file mode 100644
index ad99cf76..00000000
--- a/bot/resources/halloween/spookysounds/Monster_Alien_Grunt_Hiss_.mp3
+++ /dev/null
Binary files differ
diff --git a/bot/resources/halloween/spookysounds/sources.txt b/bot/resources/halloween/spookysounds/sources.txt
deleted file mode 100644
index 7df03c2e..00000000
--- a/bot/resources/halloween/spookysounds/sources.txt
+++ /dev/null
@@ -1,41 +0,0 @@
-Female_Monster_Growls_
-Male_Zombie_Roar_
-Monster_Alien_Growl_Calm_
-Monster_Alien_Grunt_Hiss_
-https://www.youtube.com/audiolibrary/soundeffects
-
-413315__inspectorj__something-evil-approaches-a
-https://freesound.org/people/InspectorJ/sounds/413315/
-
-133674__klankbeeld__horror-laugh-original-132802-nanakisan-evil-laugh-08
-https://freesound.org/people/klankbeeld/sounds/133674/
-
-35716__analogchill__scream
-https://freesound.org/people/analogchill/sounds/35716/
-
-249686__cylon8472__cthulhu-growl
-https://freesound.org/people/cylon8472/sounds/249686/
-
-126113__klankbeeld__laugh
-https://freesound.org/people/klankbeeld/sounds/126113/
-
-14570__oscillator__ghost-fx
-https://freesound.org/people/oscillator/sounds/14570/
-
-60571__gabemiller74__breathofdeath
-https://freesound.org/people/gabemiller74/sounds/60571/
-
-168650__0xmusex0__doorcreak
-https://freesound.org/people/0XMUSEX0/sounds/168650/
-
-193812__geoneo0__four-voices-whispering-6
-https://freesound.org/people/geoneo0/sounds/193812/
-
-109710__tomlija__horror-gate
-https://freesound.org/people/Tomlija/sounds/109710/
-
-171078__klankbeeld__horror-scream-woman-long
-https://freesound.org/people/klankbeeld/sounds/171078/
-
-237282__devilfish101__frantic-violin-screech
-https://freesound.org/people/devilfish101/sounds/237282/
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"
+}
diff --git a/bot/utils/converters.py b/bot/utils/converters.py
index 228714c9..27804170 100644
--- a/bot/utils/converters.py
+++ b/bot/utils/converters.py
@@ -1,5 +1,6 @@
import discord
-from discord.ext.commands.converter import MessageConverter
+from discord.ext.commands import BadArgument, Context
+from discord.ext.commands.converter import Converter, MessageConverter
class WrappedMessageConverter(MessageConverter):
@@ -14,3 +15,32 @@ class WrappedMessageConverter(MessageConverter):
argument = argument[1:-1]
return await super().convert(ctx, argument)
+
+
+class Subreddit(Converter):
+ """Forces a string to begin with "r/" and checks if it's a valid subreddit."""
+
+ @staticmethod
+ async def convert(ctx: Context, sub: str) -> str:
+ """
+ Force sub to begin with "r/" and check if it's a valid subreddit.
+
+ If sub is a valid subreddit, return it prepended with "r/"
+ """
+ sub = sub.lower()
+
+ if not sub.startswith("r/"):
+ sub = f"r/{sub}"
+
+ resp = await ctx.bot.http_session.get(
+ "https://www.reddit.com/subreddits/search.json",
+ params={"q": sub}
+ )
+
+ json = await resp.json()
+ if not json["data"]["children"]:
+ raise BadArgument(
+ f"The subreddit `{sub}` either doesn't exist, or it has no posts."
+ )
+
+ return sub
diff --git a/bot/utils/exceptions.py b/bot/utils/exceptions.py
index 2b1c1b31..9e080759 100644
--- a/bot/utils/exceptions.py
+++ b/bot/utils/exceptions.py
@@ -1,4 +1,4 @@
class UserNotPlayingError(Exception):
- """Will raised when user try to use game commands when not playing."""
+ """Raised when users try to use game commands when they are not playing."""
pass
diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py
new file mode 100644
index 00000000..74c2ccd0
--- /dev/null
+++ b/bot/utils/helpers.py
@@ -0,0 +1,8 @@
+import re
+
+
+def suppress_links(message: str) -> str:
+ """Accepts a message that may contain links, suppresses them, and returns them."""
+ for link in set(re.findall(r"https?://[^\s]+", message, re.IGNORECASE)):
+ message = message.replace(link, f"<{link}>")
+ return message
diff --git a/bot/utils/messages.py b/bot/utils/messages.py
new file mode 100644
index 00000000..a6c035f9
--- /dev/null
+++ b/bot/utils/messages.py
@@ -0,0 +1,19 @@
+import re
+from typing import Optional
+
+
+def sub_clyde(username: Optional[str]) -> Optional[str]:
+ """
+ Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string.
+
+ Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400.
+ Return None only if `username` is None.
+ """
+ def replace_e(match: re.Match) -> str:
+ char = "е" if match[2] == "e" else "Е"
+ return match[1] + char
+
+ if username:
+ return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I)
+ else:
+ return username # Empty string or None
diff --git a/bot/utils/pagination.py b/bot/utils/pagination.py
index a4d0cc56..917275c0 100644
--- a/bot/utils/pagination.py
+++ b/bot/utils/pagination.py
@@ -4,6 +4,7 @@ from typing import Iterable, List, Optional, Tuple
from discord import Embed, Member, Reaction
from discord.abc import User
+from discord.embeds import EmptyEmbed
from discord.ext.commands import Context, Paginator
from bot.constants import Emojis
@@ -417,9 +418,8 @@ class ImagePaginator(Paginator):
await message.edit(embed=embed)
embed.description = paginator.pages[current_page]
- image = paginator.images[current_page]
- if image:
- embed.set_image(url=image)
+ image = paginator.images[current_page] or EmptyEmbed
+ embed.set_image(url=image)
embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}")
log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}")