aboutsummaryrefslogtreecommitdiffstats
path: root/bot/exts/holidays
diff options
context:
space:
mode:
authorGravatar ChrisJL <[email protected]>2021-09-05 19:46:37 +0100
committerGravatar GitHub <[email protected]>2021-09-05 19:46:37 +0100
commit8a410f3abd39a1b48c514d32651a50d4bdced492 (patch)
tree17fbb917adec0a1283d3d2456d8b09eee0334371 /bot/exts/holidays
parentMerge pull request #845 from python-discord/Pin-platform-in-Dockerfile (diff)
parentMerge branch 'main' into lance-restructure (diff)
Merge pull request #851 from python-discord/lance-restructure
Restructure Sir Lancebot
Diffstat (limited to 'bot/exts/holidays')
-rw-r--r--bot/exts/holidays/__init__.py0
-rw-r--r--bot/exts/holidays/earth_day/__init__.py0
-rw-r--r--bot/exts/holidays/earth_day/save_the_planet.py25
-rw-r--r--bot/exts/holidays/easter/__init__.py0
-rw-r--r--bot/exts/holidays/easter/april_fools_vids.py30
-rw-r--r--bot/exts/holidays/easter/bunny_name_generator.py94
-rw-r--r--bot/exts/holidays/easter/earth_photos.py66
-rw-r--r--bot/exts/holidays/easter/easter_riddle.py112
-rw-r--r--bot/exts/holidays/easter/egg_decorating.py119
-rw-r--r--bot/exts/holidays/easter/egg_facts.py55
-rw-r--r--bot/exts/holidays/easter/egghead_quiz.py118
-rw-r--r--bot/exts/holidays/easter/traditions.py28
-rw-r--r--bot/exts/holidays/halloween/8ball.py31
-rw-r--r--bot/exts/holidays/halloween/__init__.py0
-rw-r--r--bot/exts/holidays/halloween/candy_collection.py203
-rw-r--r--bot/exts/holidays/halloween/halloween_facts.py55
-rw-r--r--bot/exts/holidays/halloween/halloweenify.py64
-rw-r--r--bot/exts/holidays/halloween/monsterbio.py54
-rw-r--r--bot/exts/holidays/halloween/monstersurvey.py205
-rw-r--r--bot/exts/holidays/halloween/scarymovie.py124
-rw-r--r--bot/exts/holidays/halloween/spookygif.py38
-rw-r--r--bot/exts/holidays/halloween/spookynamerate.py391
-rw-r--r--bot/exts/holidays/halloween/spookyrating.py67
-rw-r--r--bot/exts/holidays/halloween/spookyreact.py70
-rw-r--r--bot/exts/holidays/hanukkah/__init__.py0
-rw-r--r--bot/exts/holidays/hanukkah/hanukkah_embed.py113
-rw-r--r--bot/exts/holidays/pride/__init__.py0
-rw-r--r--bot/exts/holidays/pride/drag_queen_name.py26
-rw-r--r--bot/exts/holidays/pride/pride_anthem.py51
-rw-r--r--bot/exts/holidays/pride/pride_facts.py99
-rw-r--r--bot/exts/holidays/pride/pride_leader.py117
-rw-r--r--bot/exts/holidays/valentines/__init__.py0
-rw-r--r--bot/exts/holidays/valentines/be_my_valentine.py192
-rw-r--r--bot/exts/holidays/valentines/lovecalculator.py99
-rw-r--r--bot/exts/holidays/valentines/movie_generator.py67
-rw-r--r--bot/exts/holidays/valentines/myvalenstate.py82
-rw-r--r--bot/exts/holidays/valentines/pickuplines.py41
-rw-r--r--bot/exts/holidays/valentines/savethedate.py38
-rw-r--r--bot/exts/holidays/valentines/valentine_zodiac.py146
-rw-r--r--bot/exts/holidays/valentines/whoisvalentine.py49
40 files changed, 3069 insertions, 0 deletions
diff --git a/bot/exts/holidays/__init__.py b/bot/exts/holidays/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/__init__.py
diff --git a/bot/exts/holidays/earth_day/__init__.py b/bot/exts/holidays/earth_day/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/earth_day/__init__.py
diff --git a/bot/exts/holidays/earth_day/save_the_planet.py b/bot/exts/holidays/earth_day/save_the_planet.py
new file mode 100644
index 00000000..13c84886
--- /dev/null
+++ b/bot/exts/holidays/earth_day/save_the_planet.py
@@ -0,0 +1,25 @@
+import json
+from pathlib import Path
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.utils.randomization import RandomCycle
+
+EMBED_DATA = RandomCycle(json.loads(Path("bot/resources/holidays/earth_day/save_the_planet.json").read_text("utf8")))
+
+
+class SaveThePlanet(commands.Cog):
+ """A cog that teaches users how they can help our planet."""
+
+ @commands.command(aliases=("savetheearth", "saveplanet", "saveearth"))
+ async def savetheplanet(self, ctx: commands.Context) -> None:
+ """Responds with a random tip on how to be eco-friendly and help our planet."""
+ return_embed = Embed.from_dict(next(EMBED_DATA))
+ await ctx.send(embed=return_embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Save the Planet Cog."""
+ bot.add_cog(SaveThePlanet())
diff --git a/bot/exts/holidays/easter/__init__.py b/bot/exts/holidays/easter/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/easter/__init__.py
diff --git a/bot/exts/holidays/easter/april_fools_vids.py b/bot/exts/holidays/easter/april_fools_vids.py
new file mode 100644
index 00000000..ae22f751
--- /dev/null
+++ b/bot/exts/holidays/easter/april_fools_vids.py
@@ -0,0 +1,30 @@
+import logging
+import random
+from json import loads
+from pathlib import Path
+
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+ALL_VIDS = loads(Path("bot/resources/holidays/easter/april_fools_vids.json").read_text("utf-8"))
+
+
+class AprilFoolVideos(commands.Cog):
+ """A cog for April Fools' that gets a random April Fools' video from Youtube."""
+
+ @commands.command(name="fool")
+ async def april_fools(self, ctx: commands.Context) -> None:
+ """Get a random April Fools' video from Youtube."""
+ 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: Bot) -> None:
+ """Load the April Fools' Cog."""
+ bot.add_cog(AprilFoolVideos())
diff --git a/bot/exts/holidays/easter/bunny_name_generator.py b/bot/exts/holidays/easter/bunny_name_generator.py
new file mode 100644
index 00000000..f767f7c5
--- /dev/null
+++ b/bot/exts/holidays/easter/bunny_name_generator.py
@@ -0,0 +1,94 @@
+import json
+import logging
+import random
+import re
+from pathlib import Path
+from typing import Optional
+
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+BUNNY_NAMES = json.loads(Path("bot/resources/holidays/easter/bunny_names.json").read_text("utf8"))
+
+
+class BunnyNameGenerator(commands.Cog):
+ """Generate a random bunny name, or bunnify your Discord username!"""
+
+ @staticmethod
+ def find_separators(displayname: str) -> Optional[list[str]]:
+ """Check if Discord name contains spaces so we can bunnify an individual word in the name."""
+ new_name = re.split(r"[_.\s]", displayname)
+ if displayname not in new_name:
+ return new_name
+ return None
+
+ @staticmethod
+ def find_vowels(displayname: str) -> Optional[str]:
+ """
+ Finds vowels in the user's display name.
+
+ If the Discord name contains a vowel and the letter y, it will match one or more of these patterns.
+
+ Only the most recently matched pattern will apply the changes.
+ """
+ expressions = [
+ ("a.+y", "patchy"),
+ ("e.+y", "ears"),
+ ("i.+y", "ditsy"),
+ ("o.+y", "oofy"),
+ ("u.+y", "uffy"),
+ ]
+
+ for exp, vowel_sub in expressions:
+ new_name = re.sub(exp, vowel_sub, displayname)
+ if new_name != displayname:
+ return new_name
+
+ @staticmethod
+ def append_name(displayname: str) -> str:
+ """Adds a suffix to the end of the Discord name."""
+ extensions = ["foot", "ear", "nose", "tail"]
+ suffix = random.choice(extensions)
+ appended_name = displayname + suffix
+
+ return appended_name
+
+ @commands.command()
+ async def bunnyname(self, ctx: commands.Context) -> None:
+ """Picks a random bunny name from a JSON file."""
+ await ctx.send(random.choice(BUNNY_NAMES["names"]))
+
+ @commands.command()
+ async def bunnifyme(self, ctx: commands.Context) -> None:
+ """Gets your Discord username and bunnifies it."""
+ username = ctx.author.display_name
+
+ # If name contains spaces or other separators, get the individual words to randomly bunnify
+ spaces_in_name = self.find_separators(username)
+
+ # If name contains vowels, see if it matches any of the patterns in this function
+ # If there are matches, the bunnified name is returned.
+ vowels_in_name = self.find_vowels(username)
+
+ # Default if the checks above return None
+ unmatched_name = self.append_name(username)
+
+ if spaces_in_name is not None:
+ replacements = ["Cotton", "Fluff", "Floof" "Bounce", "Snuffle", "Nibble", "Cuddle", "Velvetpaw", "Carrot"]
+ word_to_replace = random.choice(spaces_in_name)
+ substitute = random.choice(replacements)
+ bunnified_name = username.replace(word_to_replace, substitute)
+ elif vowels_in_name is not None:
+ bunnified_name = vowels_in_name
+ elif unmatched_name:
+ bunnified_name = unmatched_name
+
+ await ctx.send(bunnified_name)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Bunny Name Generator Cog."""
+ bot.add_cog(BunnyNameGenerator())
diff --git a/bot/exts/holidays/easter/earth_photos.py b/bot/exts/holidays/easter/earth_photos.py
new file mode 100644
index 00000000..f65790af
--- /dev/null
+++ b/bot/exts/holidays/easter/earth_photos.py
@@ -0,0 +1,66 @@
+import logging
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+from bot.constants import Tokens
+
+log = logging.getLogger(__name__)
+
+API_URL = "https://api.unsplash.com/photos/random"
+
+
+class EarthPhotos(commands.Cog):
+ """This cog contains the command for earth photos."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command(aliases=("earth",))
+ async def earth_photos(self, ctx: commands.Context) -> None:
+ """Returns a random photo of earth, sourced from Unsplash."""
+ async with ctx.typing():
+ async with self.bot.http_session.get(
+ API_URL,
+ params={"query": "planet_earth", "client_id": Tokens.unsplash_access_key}
+ ) as r:
+ jsondata = await r.json()
+ linksdata = jsondata.get("urls")
+ embedlink = linksdata.get("regular")
+ downloadlinksdata = jsondata.get("links")
+ userdata = jsondata.get("user")
+ username = userdata.get("name")
+ userlinks = userdata.get("links")
+ profile = userlinks.get("html")
+ # Referral flags
+ rf = "?utm_source=Sir%20Lancebot&utm_medium=referral"
+ async with self.bot.http_session.get(
+ downloadlinksdata.get("download_location"),
+ params={"client_id": Tokens.unsplash_access_key}
+ ) as _:
+ pass
+
+ embed = discord.Embed(
+ title="Earth Photo",
+ description="A photo of Earth 🌎 from Unsplash.",
+ color=Colours.grass_green
+ )
+ embed.set_image(url=embedlink)
+ embed.add_field(
+ name="Author",
+ value=(
+ f"Photo by [{username}]({profile}{rf}) "
+ f"on [Unsplash](https://unsplash.com{rf})."
+ )
+ )
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Earth Photos cog."""
+ if not Tokens.unsplash_access_key:
+ log.warning("No Unsplash access key found. Cog not loading.")
+ return
+ bot.add_cog(EarthPhotos(bot))
diff --git a/bot/exts/holidays/easter/easter_riddle.py b/bot/exts/holidays/easter/easter_riddle.py
new file mode 100644
index 00000000..c9b7fc53
--- /dev/null
+++ b/bot/exts/holidays/easter/easter_riddle.py
@@ -0,0 +1,112 @@
+import asyncio
+import logging
+import random
+from json import loads
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, NEGATIVE_REPLIES
+
+log = logging.getLogger(__name__)
+
+RIDDLE_QUESTIONS = loads(Path("bot/resources/holidays/easter/easter_riddle.json").read_text("utf8"))
+
+TIMELIMIT = 10
+
+
+class EasterRiddle(commands.Cog):
+ """This cog contains the command for the Easter quiz!"""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.winners = set()
+ self.correct = ""
+ self.current_channel = None
+
+ @commands.command(aliases=("riddlemethis", "riddleme"))
+ async def riddle(self, ctx: commands.Context) -> None:
+ """
+ Gives a random riddle, then provides 2 hints at certain intervals before revealing the answer.
+
+ The duration of the hint interval can be configured by changing the TIMELIMIT constant in this file.
+ """
+ if self.current_channel:
+ await ctx.send(f"A riddle is already being solved in {self.current_channel.mention}!")
+ return
+
+ # 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.channel
+
+ random_question = random.choice(RIDDLE_QUESTIONS)
+ question = random_question["question"]
+ hints = random_question["riddles"]
+ self.correct = random_question["correct_answer"]
+
+ description = f"You have {TIMELIMIT} seconds before the first hint."
+
+ riddle_embed = discord.Embed(title=question, description=description, colour=Colours.pink)
+
+ await ctx.send(embed=riddle_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ hint_embed = discord.Embed(
+ title=f"Here's a hint: {hints[0]}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(embed=hint_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ hint_embed = discord.Embed(
+ title=f"Here's a hint: {hints[1]}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(embed=hint_embed)
+ await asyncio.sleep(TIMELIMIT)
+
+ if self.winners:
+ win_list = " ".join(self.winners)
+ content = f"Well done {win_list} for getting it right!"
+ else:
+ content = "Nobody got it right..."
+
+ answer_embed = discord.Embed(
+ title=f"The answer is: {self.correct}!",
+ colour=Colours.pink
+ )
+
+ await ctx.send(content, embed=answer_embed)
+
+ self.winners.clear()
+ self.current_channel = None
+
+ @commands.Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """If a non-bot user enters a correct answer, their username gets added to self.winners."""
+ if self.current_channel != message.channel:
+ return
+
+ if self.bot.user == message.author:
+ return
+
+ if message.content.lower() == self.correct.lower():
+ self.winners.add(message.author.mention)
+
+
+def setup(bot: Bot) -> None:
+ """Easter Riddle Cog load."""
+ bot.add_cog(EasterRiddle(bot))
diff --git a/bot/exts/holidays/easter/egg_decorating.py b/bot/exts/holidays/easter/egg_decorating.py
new file mode 100644
index 00000000..1db9b347
--- /dev/null
+++ b/bot/exts/holidays/easter/egg_decorating.py
@@ -0,0 +1,119 @@
+import json
+import logging
+import random
+from contextlib import suppress
+from io import BytesIO
+from pathlib import Path
+from typing import Optional, Union
+
+import discord
+from PIL import Image
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.utils import helpers
+
+log = logging.getLogger(__name__)
+
+HTML_COLOURS = json.loads(Path("bot/resources/fun/html_colours.json").read_text("utf8"))
+
+XKCD_COLOURS = json.loads(Path("bot/resources/fun/xkcd_colours.json").read_text("utf8"))
+
+COLOURS = [
+ (255, 0, 0, 255), (255, 128, 0, 255), (255, 255, 0, 255), (0, 255, 0, 255),
+ (0, 255, 255, 255), (0, 0, 255, 255), (255, 0, 255, 255), (128, 0, 128, 255)
+] # Colours to be replaced - Red, Orange, Yellow, Green, Light Blue, Dark Blue, Pink, Purple
+
+IRREPLACEABLE = [
+ (0, 0, 0, 0), (0, 0, 0, 255)
+] # Colours that are meant to stay the same - Transparent and Black
+
+
+class EggDecorating(commands.Cog):
+ """Decorate some easter eggs!"""
+
+ @staticmethod
+ def replace_invalid(colour: str) -> Optional[int]:
+ """Attempts to match with HTML or XKCD colour names, returning the int value."""
+ with suppress(KeyError):
+ return int(HTML_COLOURS[colour], 16)
+ with suppress(KeyError):
+ return int(XKCD_COLOURS[colour], 16)
+ return None
+
+ @commands.command(aliases=("decorateegg",))
+ async def eggdecorate(
+ self, ctx: commands.Context, *colours: Union[discord.Colour, str]
+ ) -> Optional[Image.Image]:
+ """
+ Picks a random egg design and decorates it using the given colours.
+
+ 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.
+ """
+ if len(colours) < 2:
+ await ctx.send("You must include at least 2 colours!")
+ return
+
+ invalid = []
+ colours = list(colours)
+ for idx, colour in enumerate(colours):
+ if isinstance(colour, discord.Colour):
+ continue
+ value = self.replace_invalid(colour)
+ if value:
+ colours[idx] = discord.Colour(value)
+ else:
+ invalid.append(helpers.suppress_links(colour))
+
+ if len(invalid) > 1:
+ await ctx.send(f"Sorry, I don't know these colours: {' '.join(invalid)}")
+ return
+ elif len(invalid) == 1:
+ await ctx.send(f"Sorry, I don't know the colour {invalid[0]}!")
+ return
+
+ async with ctx.typing():
+ # Expand list to 8 colours
+ colours_n = len(colours)
+ if colours_n < 8:
+ q, r = divmod(8, colours_n)
+ colours = colours * q + colours[:r]
+ num = random.randint(1, 6)
+ im = Image.open(Path(f"bot/resources/holidays/easter/easter_eggs/design{num}.png"))
+ data = list(im.getdata())
+
+ replaceable = {x for x in data if x not in IRREPLACEABLE}
+ replaceable = sorted(replaceable, key=COLOURS.index)
+
+ replacing_colours = {colour: colours[i] for i, colour in enumerate(replaceable)}
+ new_data = []
+ for x in data:
+ if x in replacing_colours:
+ new_data.append((*replacing_colours[x].to_rgb(), 255))
+ # Also ensures that the alpha channel has a value
+ else:
+ new_data.append(x)
+ new_im = Image.new(im.mode, im.size)
+ new_im.putdata(new_data)
+
+ bufferedio = BytesIO()
+ new_im.save(bufferedio, format="PNG")
+
+ bufferedio.seek(0)
+
+ file = discord.File(bufferedio, filename="egg.png") # Creates file to be used in embed
+ embed = discord.Embed(
+ title="Your Colourful Easter Egg",
+ description="Here is your pretty little egg. Hope you like it!"
+ )
+ embed.set_image(url="attachment://egg.png")
+ embed.set_footer(text=f"Made by {ctx.author.display_name}", icon_url=ctx.author.display_avatar.url)
+
+ await ctx.send(file=file, embed=embed)
+ return new_im
+
+
+def setup(bot: Bot) -> None:
+ """Load the Egg decorating Cog."""
+ bot.add_cog(EggDecorating())
diff --git a/bot/exts/holidays/easter/egg_facts.py b/bot/exts/holidays/easter/egg_facts.py
new file mode 100644
index 00000000..5f216e0d
--- /dev/null
+++ b/bot/exts/holidays/easter/egg_facts.py
@@ -0,0 +1,55 @@
+import logging
+import random
+from json import loads
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Channels, Colours, Month
+from bot.utils.decorators import seasonal_task
+
+log = logging.getLogger(__name__)
+
+EGG_FACTS = loads(Path("bot/resources/holidays/easter/easter_egg_facts.json").read_text("utf8"))
+
+
+class EasterFacts(commands.Cog):
+ """
+ A cog contains a command that will return an easter egg fact when called.
+
+ It also contains a background task which sends an easter egg fact in the event channel everyday.
+ """
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.daily_fact_task = self.bot.loop.create_task(self.send_egg_fact_daily())
+
+ @seasonal_task(Month.APRIL)
+ async def send_egg_fact_daily(self) -> None:
+ """A background task that sends an easter egg fact in the event channel everyday."""
+ await self.bot.wait_until_guild_available()
+
+ channel = self.bot.get_channel(Channels.community_bot_commands)
+ await channel.send(embed=self.make_embed())
+
+ @commands.command(name="eggfact", aliases=("fact",))
+ async def easter_facts(self, ctx: commands.Context) -> None:
+ """Get easter egg facts."""
+ embed = self.make_embed()
+ await ctx.send(embed=embed)
+
+ @staticmethod
+ def make_embed() -> discord.Embed:
+ """Makes a nice embed for the message to be sent."""
+ return discord.Embed(
+ colour=Colours.soft_red,
+ title="Easter Egg Fact",
+ description=random.choice(EGG_FACTS)
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the Easter Egg facts Cog."""
+ bot.add_cog(EasterFacts(bot))
diff --git a/bot/exts/holidays/easter/egghead_quiz.py b/bot/exts/holidays/easter/egghead_quiz.py
new file mode 100644
index 00000000..06229537
--- /dev/null
+++ b/bot/exts/holidays/easter/egghead_quiz.py
@@ -0,0 +1,118 @@
+import asyncio
+import logging
+import random
+from json import loads
+from pathlib import Path
+from typing import Union
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+EGGHEAD_QUESTIONS = loads(Path("bot/resources/holidays/easter/egghead_questions.json").read_text("utf8"))
+
+
+EMOJIS = [
+ "\U0001f1e6", "\U0001f1e7", "\U0001f1e8", "\U0001f1e9", "\U0001f1ea",
+ "\U0001f1eb", "\U0001f1ec", "\U0001f1ed", "\U0001f1ee", "\U0001f1ef",
+ "\U0001f1f0", "\U0001f1f1", "\U0001f1f2", "\U0001f1f3", "\U0001f1f4",
+ "\U0001f1f5", "\U0001f1f6", "\U0001f1f7", "\U0001f1f8", "\U0001f1f9",
+ "\U0001f1fa", "\U0001f1fb", "\U0001f1fc", "\U0001f1fd", "\U0001f1fe",
+ "\U0001f1ff"
+] # Regional Indicators A-Z (used for voting)
+
+TIMELIMIT = 30
+
+
+class EggheadQuiz(commands.Cog):
+ """This cog contains the command for the Easter quiz!"""
+
+ def __init__(self):
+ self.quiz_messages = {}
+
+ @commands.command(aliases=("eggheadquiz", "easterquiz"))
+ async def eggquiz(self, ctx: commands.Context) -> None:
+ """
+ Gives a random quiz question, waits 30 seconds and then outputs the answer.
+
+ Also informs of the percentages and votes of each option
+ """
+ random_question = random.choice(EGGHEAD_QUESTIONS)
+ question, answers = random_question["question"], random_question["answers"]
+ answers = [(EMOJIS[i], a) for i, a in enumerate(answers)]
+ correct = EMOJIS[random_question["correct_answer"]]
+
+ valid_emojis = [emoji for emoji, _ in answers]
+
+ description = f"You have {TIMELIMIT} seconds to vote.\n\n"
+ description += "\n".join([f"{emoji} -> **{answer}**" for emoji, answer in answers])
+
+ q_embed = discord.Embed(title=question, description=description, colour=Colours.pink)
+
+ msg = await ctx.send(embed=q_embed)
+ for emoji in valid_emojis:
+ await msg.add_reaction(emoji)
+
+ self.quiz_messages[msg.id] = valid_emojis
+
+ await asyncio.sleep(TIMELIMIT)
+
+ del self.quiz_messages[msg.id]
+
+ msg = await ctx.fetch_message(msg.id) # Refreshes message
+
+ total_no = sum([len(await r.users().flatten()) for r in msg.reactions]) - len(valid_emojis) # - bot's reactions
+
+ if total_no == 0:
+ return await msg.delete() # To avoid ZeroDivisionError if nobody reacts
+
+ results = ["**VOTES:**"]
+ for emoji, _ in answers:
+ num = [len(await r.users().flatten()) for r in msg.reactions if str(r.emoji) == emoji][0] - 1
+ percent = round(100 * num / total_no)
+ s = "" if num == 1 else "s"
+ string = f"{emoji} - {num} vote{s} ({percent}%)"
+ results.append(string)
+
+ mentions = " ".join([
+ u.mention for u in [
+ await r.users().flatten() for r in msg.reactions if str(r.emoji) == correct
+ ][0] if not u.bot
+ ])
+
+ content = f"Well done {mentions} for getting it correct!" if mentions else "Nobody got it right..."
+
+ a_embed = discord.Embed(
+ title=f"The correct answer was {correct}!",
+ description="\n".join(results),
+ colour=Colours.pink
+ )
+
+ await ctx.send(content, embed=a_embed)
+
+ @staticmethod
+ async def already_reacted(message: discord.Message, user: Union[discord.Member, discord.User]) -> bool:
+ """Returns whether a given user has reacted more than once to a given message."""
+ users = [u.id for reaction in [await r.users().flatten() for r in message.reactions] for u in reaction]
+ return users.count(user.id) > 1 # Old reaction plus new reaction
+
+ @commands.Cog.listener()
+ async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.Member, discord.User]) -> None:
+ """Listener to listen specifically for reactions of quiz messages."""
+ if user.bot:
+ return
+ if reaction.message.id not in self.quiz_messages:
+ return
+ if str(reaction.emoji) not in self.quiz_messages[reaction.message.id]:
+ return await reaction.message.remove_reaction(reaction, user)
+ if await self.already_reacted(reaction.message, user):
+ return await reaction.message.remove_reaction(reaction, user)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Egghead Quiz Cog."""
+ bot.add_cog(EggheadQuiz())
diff --git a/bot/exts/holidays/easter/traditions.py b/bot/exts/holidays/easter/traditions.py
new file mode 100644
index 00000000..f54ab5c4
--- /dev/null
+++ b/bot/exts/holidays/easter/traditions.py
@@ -0,0 +1,28 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+traditions = json.loads(Path("bot/resources/holidays/easter/traditions.json").read_text("utf8"))
+
+
+class Traditions(commands.Cog):
+ """A cog which allows users to get a random easter tradition or custom from a random country."""
+
+ @commands.command(aliases=("eastercustoms",))
+ async def easter_tradition(self, ctx: commands.Context) -> None:
+ """Responds with a random tradition or custom."""
+ random_country = random.choice(list(traditions))
+
+ await ctx.send(f"{random_country}:\n{traditions[random_country]}")
+
+
+def setup(bot: Bot) -> None:
+ """Load the Traditions Cog."""
+ bot.add_cog(Traditions())
diff --git a/bot/exts/holidays/halloween/8ball.py b/bot/exts/holidays/halloween/8ball.py
new file mode 100644
index 00000000..4fec8463
--- /dev/null
+++ b/bot/exts/holidays/halloween/8ball.py
@@ -0,0 +1,31 @@
+import asyncio
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+RESPONSES = json.loads(Path("bot/resources/holidays/halloween/responses.json").read_text("utf8"))
+
+
+class SpookyEightBall(commands.Cog):
+ """Spooky Eightball answers."""
+
+ @commands.command(aliases=("spooky8ball",))
+ async def spookyeightball(self, ctx: commands.Context, *, question: str) -> None:
+ """Responds with a random response to a question."""
+ choice = random.choice(RESPONSES["responses"])
+ msg = await ctx.send(choice[0])
+ if len(choice) > 1:
+ await asyncio.sleep(random.randint(2, 5))
+ await msg.edit(content=f"{choice[0]} \n{choice[1]}")
+
+
+def setup(bot: Bot) -> None:
+ """Load the Spooky Eight Ball Cog."""
+ bot.add_cog(SpookyEightBall())
diff --git a/bot/exts/holidays/halloween/__init__.py b/bot/exts/holidays/halloween/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/halloween/__init__.py
diff --git a/bot/exts/holidays/halloween/candy_collection.py b/bot/exts/holidays/halloween/candy_collection.py
new file mode 100644
index 00000000..4afd5913
--- /dev/null
+++ b/bot/exts/holidays/halloween/candy_collection.py
@@ -0,0 +1,203 @@
+import logging
+import random
+from typing import Union
+
+import discord
+from async_rediscache import RedisCache
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Channels, Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+# chance is 1 in x range, so 1 in 20 range would give 5% chance (for add candy)
+ADD_CANDY_REACTION_CHANCE = 20 # 5%
+ADD_CANDY_EXISTING_REACTION_CHANCE = 10 # 10%
+ADD_SKULL_REACTION_CHANCE = 50 # 2%
+ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5%
+
+EMOJIS = dict(
+ CANDY="\N{CANDY}",
+ SKULL="\N{SKULL}",
+ MEDALS=(
+ "\N{FIRST PLACE MEDAL}",
+ "\N{SECOND PLACE MEDAL}",
+ "\N{THIRD PLACE MEDAL}",
+ "\N{SPORTS MEDAL}",
+ "\N{SPORTS MEDAL}",
+ ),
+)
+
+
+class CandyCollection(commands.Cog):
+ """Candy collection game Cog."""
+
+ # User candy amount records
+ candy_records = RedisCache()
+
+ # Candy and skull messages mapping
+ candy_messages = RedisCache()
+ skull_messages = RedisCache()
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @in_month(Month.OCTOBER)
+ @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
+ # ensure it's hacktober channel
+ if message.channel.id != Channels.community_bot_commands:
+ return
+
+ # do random check for skull first as it has the lower chance
+ if random.randint(1, ADD_SKULL_REACTION_CHANCE) == 1:
+ await self.skull_messages.set(message.id, "skull")
+ await message.add_reaction(EMOJIS["SKULL"])
+ # check for the candy chance next
+ elif random.randint(1, ADD_CANDY_REACTION_CHANCE) == 1:
+ await self.candy_messages.set(message.id, "candy")
+ await message.add_reaction(EMOJIS["CANDY"])
+
+ @in_month(Month.OCTOBER)
+ @commands.Cog.listener()
+ async def on_reaction_add(self, reaction: discord.Reaction, user: Union[discord.User, discord.Member]) -> None:
+ """Add/remove candies from a person if the reaction satisfies criteria."""
+ message = reaction.message
+ # check to ensure the reactor is human
+ if user.bot:
+ return
+
+ # check to ensure it is in correct channel
+ if message.channel.id != Channels.community_bot_commands:
+ return
+
+ # if its not a candy or skull, and it is one of 10 most recent messages,
+ # proceed to add a skull/candy with higher chance
+ if str(reaction.emoji) not in (EMOJIS["SKULL"], EMOJIS["CANDY"]):
+ recent_message_ids = map(
+ lambda m: m.id,
+ await self.hacktober_channel.history(limit=10).flatten()
+ )
+ if message.id in recent_message_ids:
+ await self.reacted_msg_chance(message)
+ return
+
+ if await self.candy_messages.get(message.id) == "candy" and str(reaction.emoji) == EMOJIS["CANDY"]:
+ await self.candy_messages.delete(message.id)
+ if await self.candy_records.contains(user.id):
+ await self.candy_records.increment(user.id)
+ else:
+ await self.candy_records.set(user.id, 1)
+
+ elif await self.skull_messages.get(message.id) == "skull" and str(reaction.emoji) == EMOJIS["SKULL"]:
+ await self.skull_messages.delete(message.id)
+
+ if prev_record := await self.candy_records.get(user.id):
+ lost = min(random.randint(1, 3), prev_record)
+ await self.candy_records.decrement(user.id, lost)
+
+ if lost == prev_record:
+ await CandyCollection.send_spook_msg(user, message.channel, "all of your")
+ else:
+ await CandyCollection.send_spook_msg(user, message.channel, lost)
+ else:
+ await CandyCollection.send_no_candy_spook_message(user, message.channel)
+ else:
+ return # Skip saving
+
+ await reaction.clear()
+
+ async def reacted_msg_chance(self, message: discord.Message) -> None:
+ """
+ Randomly add a skull or candy reaction to a message if there is a reaction there already.
+
+ This event has a higher probability of occurring than a reaction add to a message without an
+ existing reaction.
+ """
+ if random.randint(1, ADD_SKULL_EXISTING_REACTION_CHANCE) == 1:
+ await self.skull_messages.set(message.id, "skull")
+ await message.add_reaction(EMOJIS["SKULL"])
+
+ elif random.randint(1, ADD_CANDY_EXISTING_REACTION_CHANCE) == 1:
+ await self.candy_messages.set(message.id, "candy")
+ await message.add_reaction(EMOJIS["CANDY"])
+
+ @property
+ def hacktober_channel(self) -> discord.TextChannel:
+ """Get #hacktoberbot channel from its ID."""
+ return self.bot.get_channel(id=Channels.community_bot_commands)
+
+ @staticmethod
+ async def send_spook_msg(
+ author: discord.Member, channel: discord.TextChannel, candies: Union[str, int]
+ ) -> None:
+ """Send a spooky message."""
+ e = discord.Embed(colour=author.colour)
+ e.set_author(
+ name="Ghosts and Ghouls and Jack o' lanterns at night; "
+ f"I took {candies} candies and quickly took flight."
+ )
+ await channel.send(embed=e)
+
+ @staticmethod
+ async def send_no_candy_spook_message(
+ author: discord.Member,
+ channel: discord.TextChannel
+ ) -> None:
+ """An alternative spooky message sent when user has no candies in the collection."""
+ embed = discord.Embed(color=author.color)
+ embed.set_author(
+ name=(
+ "Ghosts and Ghouls and Jack o' lanterns at night; "
+ "I tried to take your candies but you had none to begin with!"
+ )
+ )
+ await channel.send(embed=embed)
+
+ @in_month(Month.OCTOBER)
+ @commands.command()
+ async def candy(self, ctx: commands.Context) -> None:
+ """Get the candy leaderboard and save to JSON."""
+ records = await self.candy_records.items()
+
+ def generate_leaderboard() -> str:
+ top_sorted = sorted(
+ ((user_id, score) for user_id, score in records if score > 0),
+ key=lambda x: x[1],
+ reverse=True
+ )
+ top_five = top_sorted[:5]
+
+ return "\n".join(
+ f"{EMOJIS['MEDALS'][index]} <@{record[0]}>: {record[1]}"
+ for index, record in enumerate(top_five)
+ ) if top_five else "No Candies"
+
+ e = discord.Embed(colour=discord.Colour.blurple())
+ e.add_field(
+ name="Top Candy Records",
+ value=generate_leaderboard(),
+ inline=False
+ )
+ e.add_field(
+ name="\u200b",
+ value="Candies will randomly appear on messages sent. "
+ "\nHit the candy when it appears as fast as possible to get the candy! "
+ "\nBut beware the ghosts...",
+ inline=False
+ )
+ await ctx.send(embed=e)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Candy Collection Cog."""
+ bot.add_cog(CandyCollection(bot))
diff --git a/bot/exts/holidays/halloween/halloween_facts.py b/bot/exts/holidays/halloween/halloween_facts.py
new file mode 100644
index 00000000..adde2310
--- /dev/null
+++ b/bot/exts/holidays/halloween/halloween_facts.py
@@ -0,0 +1,55 @@
+import json
+import logging
+import random
+from datetime import timedelta
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+SPOOKY_EMOJIS = [
+ "\N{BAT}",
+ "\N{DERELICT HOUSE BUILDING}",
+ "\N{EXTRATERRESTRIAL ALIEN}",
+ "\N{GHOST}",
+ "\N{JACK-O-LANTERN}",
+ "\N{SKULL}",
+ "\N{SKULL AND CROSSBONES}",
+ "\N{SPIDER WEB}",
+]
+PUMPKIN_ORANGE = 0xFF7518
+INTERVAL = timedelta(hours=6).total_seconds()
+
+FACTS = json.loads(Path("bot/resources/holidays/halloween/halloween_facts.json").read_text("utf8"))
+FACTS = list(enumerate(FACTS))
+
+
+class HalloweenFacts(commands.Cog):
+ """A Cog for displaying interesting facts about Halloween."""
+
+ def random_fact(self) -> tuple[int, str]:
+ """Return a random fact from the loaded facts."""
+ return random.choice(FACTS)
+
+ @commands.command(name="spookyfact", aliases=("halloweenfact",), brief="Get the most recent Halloween fact")
+ async def get_random_fact(self, ctx: commands.Context) -> None:
+ """Reply with the most recent Halloween fact."""
+ index, fact = self.random_fact()
+ embed = self._build_embed(index, fact)
+ await ctx.send(embed=embed)
+
+ @staticmethod
+ def _build_embed(index: int, fact: str) -> discord.Embed:
+ """Builds a Discord embed from the given fact and its index."""
+ emoji = random.choice(SPOOKY_EMOJIS)
+ title = f"{emoji} Halloween Fact #{index + 1}"
+ return discord.Embed(title=title, description=fact, color=PUMPKIN_ORANGE)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Halloween Facts Cog."""
+ bot.add_cog(HalloweenFacts())
diff --git a/bot/exts/holidays/halloween/halloweenify.py b/bot/exts/holidays/halloween/halloweenify.py
new file mode 100644
index 00000000..03b52589
--- /dev/null
+++ b/bot/exts/holidays/halloween/halloweenify.py
@@ -0,0 +1,64 @@
+import logging
+from json import loads
+from pathlib import Path
+from random import choice
+
+import discord
+from discord.errors import Forbidden
+from discord.ext import commands
+from discord.ext.commands import BucketType
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+HALLOWEENIFY_DATA = loads(Path("bot/resources/holidays/halloween/halloweenify.json").read_text("utf8"))
+
+
+class Halloweenify(commands.Cog):
+ """A cog to change a invokers nickname to a spooky one!"""
+
+ @commands.cooldown(1, 300, BucketType.user)
+ @commands.command()
+ async def halloweenify(self, ctx: commands.Context) -> None:
+ """Change your nickname into a much spookier one!"""
+ async with ctx.typing():
+ # Choose a random character from our list we loaded above and set apart the nickname and image url.
+ character = choice(HALLOWEENIFY_DATA["characters"])
+ nickname = "".join(nickname for nickname in character)
+ image = "".join(character[nickname] for nickname in character)
+
+ # Build up a Embed
+ embed = discord.Embed()
+ embed.colour = discord.Colour.dark_orange()
+ embed.title = "Not spooky enough?"
+ embed.description = (
+ f"**{ctx.author.display_name}** wasn't spooky enough for you? That's understandable, "
+ f"{ctx.author.display_name} isn't scary at all! "
+ "Let me think of something better. Hmm... I got it!\n\n "
+ )
+ embed.set_image(url=image)
+
+ if isinstance(ctx.author, discord.Member):
+ try:
+ await ctx.author.edit(nick=nickname)
+ embed.description += f"Your new nickname will be: \n:ghost: **{nickname}** :jack_o_lantern:"
+
+ except Forbidden: # The bot doesn't have enough permission
+ embed.description += (
+ f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n"
+ f"It looks like I cannot change your name, but feel free to change it yourself."
+ )
+
+ else: # The command has been invoked in DM
+ embed.description += (
+ f"Your new nickname should be: \n :ghost: **{nickname}** :jack_o_lantern: \n\n"
+ f"Feel free to change it yourself, or invoke the command again inside the server."
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Halloweenify Cog."""
+ bot.add_cog(Halloweenify())
diff --git a/bot/exts/holidays/halloween/monsterbio.py b/bot/exts/holidays/halloween/monsterbio.py
new file mode 100644
index 00000000..0556a193
--- /dev/null
+++ b/bot/exts/holidays/halloween/monsterbio.py
@@ -0,0 +1,54 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+TEXT_OPTIONS = json.loads(
+ Path("bot/resources/holidays/halloween/monster.json").read_text("utf8")
+) # Data for a mad-lib style generation of text
+
+
+class MonsterBio(commands.Cog):
+ """A cog that generates a spooky monster biography."""
+
+ def generate_name(self, seeded_random: random.Random) -> str:
+ """Generates a name (for either monster species or monster name)."""
+ n_candidate_strings = seeded_random.randint(2, len(TEXT_OPTIONS["monster_type"]))
+ return "".join(seeded_random.choice(TEXT_OPTIONS["monster_type"][i]) for i in range(n_candidate_strings))
+
+ @commands.command(brief="Sends your monster bio!")
+ async def monsterbio(self, ctx: commands.Context) -> None:
+ """Sends a description of a monster."""
+ seeded_random = random.Random(ctx.author.id) # Seed a local Random instance rather than the system one
+
+ name = self.generate_name(seeded_random)
+ species = self.generate_name(seeded_random)
+ biography_text = seeded_random.choice(TEXT_OPTIONS["biography_text"])
+ words = {"monster_name": name, "monster_species": species}
+ for key, value in biography_text.items():
+ if key == "text":
+ continue
+
+ options = seeded_random.sample(TEXT_OPTIONS[key], value)
+ words[key] = " ".join(options)
+
+ embed = discord.Embed(
+ title=f"{name}'s Biography",
+ color=seeded_random.choice([Colours.orange, Colours.purple]),
+ description=biography_text["text"].format_map(words),
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Monster Bio Cog."""
+ bot.add_cog(MonsterBio())
diff --git a/bot/exts/holidays/halloween/monstersurvey.py b/bot/exts/holidays/halloween/monstersurvey.py
new file mode 100644
index 00000000..f3433886
--- /dev/null
+++ b/bot/exts/holidays/halloween/monstersurvey.py
@@ -0,0 +1,205 @@
+import json
+import logging
+import pathlib
+
+from discord import Embed
+from discord.ext import commands
+from discord.ext.commands import Bot, Cog, Context
+
+log = logging.getLogger(__name__)
+
+EMOJIS = {
+ "SUCCESS": u"\u2705",
+ "ERROR": u"\u274C"
+}
+
+
+class MonsterSurvey(Cog):
+ """
+ Vote for your favorite monster.
+
+ This Cog allows users to vote for their favorite listed monster.
+
+ Users may change their vote, but only their current vote will be counted.
+ """
+
+ def __init__(self):
+ """Initializes values for the bot to use within the voting commands."""
+ self.registry_path = pathlib.Path("bot", "resources", "holidays", "halloween", "monstersurvey.json")
+ self.voter_registry = json.loads(self.registry_path.read_text("utf8"))
+
+ def json_write(self) -> None:
+ """Write voting results to a local JSON file."""
+ log.info("Saved Monster Survey Results")
+ self.registry_path.write_text(json.dumps(self.voter_registry, indent=2))
+
+ def cast_vote(self, id: int, monster: str) -> None:
+ """
+ Cast a user's vote for the specified monster.
+
+ If the user has already voted, their existing vote is removed.
+ """
+ vr = self.voter_registry
+ for m in vr:
+ if id not in vr[m]["votes"] and m == monster:
+ vr[m]["votes"].append(id)
+ else:
+ if id in vr[m]["votes"] and m != monster:
+ vr[m]["votes"].remove(id)
+
+ def get_name_by_leaderboard_index(self, n: int) -> str:
+ """Return the monster at the specified leaderboard index."""
+ n = n - 1
+ vr = self.voter_registry
+ top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True)
+ name = top[n] if n >= 0 else None
+ return name
+
+ @commands.group(
+ name="monster",
+ aliases=("mon",)
+ )
+ async def monster_group(self, ctx: Context) -> None:
+ """The base voting command. If nothing is called, then it will return an embed."""
+ if ctx.invoked_subcommand is None:
+ async with ctx.typing():
+ default_embed = Embed(
+ title="Monster Voting",
+ color=0xFF6800,
+ description="Vote for your favorite monster!"
+ )
+ default_embed.add_field(
+ name=".monster show monster_name(optional)",
+ value="Show a specific monster. If none is listed, it will give you an error with valid choices.",
+ inline=False
+ )
+ default_embed.add_field(
+ name=".monster vote monster_name",
+ value="Vote for a specific monster. You get one vote, but can change it at any time.",
+ inline=False
+ )
+ default_embed.add_field(
+ name=".monster leaderboard",
+ value="Which monster has the most votes? This command will tell you.",
+ inline=False
+ )
+ default_embed.set_footer(text=f"Monsters choices are: {', '.join(self.voter_registry)}")
+
+ await ctx.send(embed=default_embed)
+
+ @monster_group.command(
+ name="vote"
+ )
+ async def monster_vote(self, ctx: Context, name: str = None) -> None:
+ """
+ Cast a vote for a particular monster.
+
+ Displays a list of monsters that can be voted for if one is not specified.
+ """
+ if name is None:
+ await ctx.invoke(self.monster_leaderboard)
+ return
+
+ async with ctx.typing():
+ # Check to see if user used a numeric (leaderboard) index to vote
+ try:
+ idx = int(name)
+ name = self.get_name_by_leaderboard_index(idx)
+ except ValueError:
+ name = name.lower()
+
+ vote_embed = Embed(
+ name="Monster Voting",
+ color=0xFF6800
+ )
+
+ m = self.voter_registry.get(name)
+ if m is None:
+ vote_embed.description = f"You cannot vote for {name} because it's not in the running."
+ vote_embed.add_field(
+ name="Use `.monster show {monster_name}` for more information on a specific monster",
+ value="or use `.monster vote {monster}` to cast your vote for said monster.",
+ inline=False
+ )
+ vote_embed.add_field(
+ name="You may vote for or show the following monsters:",
+ value=", ".join(self.voter_registry.keys())
+ )
+ else:
+ self.cast_vote(ctx.author.id, name)
+ vote_embed.add_field(
+ name="Vote successful!",
+ value=f"You have successfully voted for {m['full_name']}!",
+ inline=False
+ )
+ vote_embed.set_thumbnail(url=m["image"])
+ vote_embed.set_footer(text="Please note that any previous votes have been removed.")
+ self.json_write()
+
+ await ctx.send(embed=vote_embed)
+
+ @monster_group.command(
+ name="show"
+ )
+ async def monster_show(self, ctx: Context, name: str = None) -> None:
+ """Shows the named monster. If one is not named, it sends the default voting embed instead."""
+ if name is None:
+ await ctx.invoke(self.monster_leaderboard)
+ return
+
+ async with ctx.typing():
+ # Check to see if user used a numeric (leaderboard) index to vote
+ try:
+ idx = int(name)
+ name = self.get_name_by_leaderboard_index(idx)
+ except ValueError:
+ name = name.lower()
+
+ m = self.voter_registry.get(name)
+ if not m:
+ await ctx.send("That monster does not exist.")
+ await ctx.invoke(self.monster_vote)
+ return
+
+ embed = Embed(title=m["full_name"], color=0xFF6800)
+ embed.add_field(name="Summary", value=m["summary"])
+ embed.set_image(url=m["image"])
+ embed.set_footer(text=f"To vote for this monster, type .monster vote {name}")
+
+ await ctx.send(embed=embed)
+
+ @monster_group.command(
+ name="leaderboard",
+ aliases=("lb",)
+ )
+ async def monster_leaderboard(self, ctx: Context) -> None:
+ """Shows the current standings."""
+ async with ctx.typing():
+ vr = self.voter_registry
+ top = sorted(vr, key=lambda k: len(vr[k]["votes"]), reverse=True)
+ total_votes = sum(len(m["votes"]) for m in self.voter_registry.values())
+
+ embed = Embed(title="Monster Survey Leader Board", color=0xFF6800)
+ for rank, m in enumerate(top):
+ votes = len(vr[m]["votes"])
+ percentage = ((votes / total_votes) * 100) if total_votes > 0 else 0
+ embed.add_field(
+ name=f"{rank+1}. {vr[m]['full_name']}",
+ value=(
+ f"{votes} votes. {percentage:.1f}% of total votes.\n"
+ f"Vote for this monster by typing "
+ f"'.monster vote {m}'\n"
+ f"Get more information on this monster by typing "
+ f"'.monster show {m}'"
+ ),
+ inline=False
+ )
+
+ embed.set_footer(text="You can also vote by their rank number. '.monster vote {number}' ")
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Monster Survey Cog."""
+ bot.add_cog(MonsterSurvey())
diff --git a/bot/exts/holidays/halloween/scarymovie.py b/bot/exts/holidays/halloween/scarymovie.py
new file mode 100644
index 00000000..33659fd8
--- /dev/null
+++ b/bot/exts/holidays/halloween/scarymovie.py
@@ -0,0 +1,124 @@
+import logging
+import random
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Tokens
+log = logging.getLogger(__name__)
+
+
+class ScaryMovie(commands.Cog):
+ """Selects a random scary movie and embeds info into Discord chat."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command(name="scarymovie", alias=["smovie"])
+ async def random_movie(self, ctx: commands.Context) -> None:
+ """Randomly select a scary movie and display information about it."""
+ async with ctx.typing():
+ selection = await self.select_movie()
+ movie_details = await self.format_metadata(selection)
+
+ await ctx.send(embed=movie_details)
+
+ async def select_movie(self) -> dict:
+ """Selects a random movie and returns a JSON of movie details from TMDb."""
+ url = "https://api.themoviedb.org/3/discover/movie"
+ params = {
+ "api_key": Tokens.tmdb,
+ "with_genres": "27",
+ "vote_count.gte": "5",
+ "include_adult": "false"
+ }
+ headers = {
+ "Content-Type": "application/json;charset=utf-8"
+ }
+
+ # Get total page count of horror movies
+ async with self.bot.http_session.get(url=url, params=params, headers=headers) as response:
+ data = await response.json()
+ total_pages = data.get("total_pages")
+
+ # Get movie details from one random result on a random page
+ params["page"] = random.randint(1, total_pages)
+ async with self.bot.http_session.get(url=url, params=params, headers=headers) as response:
+ data = await response.json()
+ selection_id = random.choice(data.get("results")).get("id")
+
+ # Get full details and credits
+ async with self.bot.http_session.get(
+ url=f"https://api.themoviedb.org/3/movie/{selection_id}",
+ params={"api_key": Tokens.tmdb, "append_to_response": "credits"}
+ ) as selection:
+
+ return await selection.json()
+
+ @staticmethod
+ async def format_metadata(movie: dict) -> Embed:
+ """Formats raw TMDb data to be embedded in Discord chat."""
+ # Build the relevant URLs.
+ movie_id = movie.get("id")
+ poster_path = movie.get("poster_path")
+ tmdb_url = f"https://www.themoviedb.org/movie/{movie_id}" if movie_id else None
+ poster = f"https://image.tmdb.org/t/p/original{poster_path}" if poster_path else None
+
+ # Get cast names
+ cast = []
+ for actor in movie.get("credits", {}).get("cast", [])[:3]:
+ cast.append(actor.get("name"))
+
+ # Get director name
+ director = movie.get("credits", {}).get("crew", [])
+ if director:
+ director = director[0].get("name")
+
+ # Determine the spookiness rating
+ rating = ""
+ rating_count = movie.get("vote_average", 0) / 2
+
+ for _ in range(int(rating_count)):
+ rating += ":skull:"
+ if (rating_count % 1) >= .5:
+ rating += ":bat:"
+
+ # Try to get year of release and runtime
+ year = movie.get("release_date", [])[:4]
+ runtime = movie.get("runtime")
+ runtime = f"{runtime} minutes" if runtime else None
+
+ # Not all these attributes will always be present
+ movie_attributes = {
+ "Directed by": director,
+ "Starring": ", ".join(cast),
+ "Running time": runtime,
+ "Release year": year,
+ "Spookiness rating": rating,
+ }
+
+ embed = Embed(
+ colour=0x01d277,
+ title=f"**{movie.get('title')}**",
+ url=tmdb_url,
+ description=movie.get("overview")
+ )
+
+ if poster:
+ embed.set_image(url=poster)
+
+ # Add the attributes that we actually have data for, but not the others.
+ for name, value in movie_attributes.items():
+ if value:
+ embed.add_field(name=name, value=value)
+
+ embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.")
+ embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")
+
+ return embed
+
+
+def setup(bot: Bot) -> None:
+ """Load the Scary Movie Cog."""
+ bot.add_cog(ScaryMovie(bot))
diff --git a/bot/exts/holidays/halloween/spookygif.py b/bot/exts/holidays/halloween/spookygif.py
new file mode 100644
index 00000000..9511d407
--- /dev/null
+++ b/bot/exts/holidays/halloween/spookygif.py
@@ -0,0 +1,38 @@
+import logging
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, Tokens
+
+log = logging.getLogger(__name__)
+
+API_URL = "http://api.giphy.com/v1/gifs/random"
+
+
+class SpookyGif(commands.Cog):
+ """A cog to fetch a random spooky gif from the web!"""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command(name="spookygif", aliases=("sgif", "scarygif"))
+ async def spookygif(self, ctx: commands.Context) -> None:
+ """Fetches a random gif from the GIPHY API and responds with it."""
+ async with ctx.typing():
+ params = {"api_key": Tokens.giphy, "tag": "halloween", "rating": "g"}
+ # Make a GET request to the Giphy API to get a random halloween gif.
+ async with self.bot.http_session.get(API_URL, params=params) as resp:
+ data = await resp.json()
+ url = data["data"]["image_url"]
+
+ embed = discord.Embed(title="A spooooky gif!", colour=Colours.purple)
+ embed.set_image(url=url)
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Spooky GIF Cog load."""
+ bot.add_cog(SpookyGif(bot))
diff --git a/bot/exts/holidays/halloween/spookynamerate.py b/bot/exts/holidays/halloween/spookynamerate.py
new file mode 100644
index 00000000..2e59d4a8
--- /dev/null
+++ b/bot/exts/holidays/halloween/spookynamerate.py
@@ -0,0 +1,391 @@
+import asyncio
+import json
+import random
+from collections import defaultdict
+from datetime import datetime, timedelta
+from logging import getLogger
+from os import getenv
+from pathlib import Path
+from typing import Optional
+
+from async_rediscache import RedisCache
+from discord import Embed, Reaction, TextChannel, User
+from discord.colour import Colour
+from discord.ext import tasks
+from discord.ext.commands import Cog, Context, group
+
+from bot.bot import Bot
+from bot.constants import Channels, Client, Colours, Month
+from bot.utils.decorators import InMonthCheckFailure
+
+logger = getLogger(__name__)
+
+EMOJIS_VAL = {
+ "\N{Jack-O-Lantern}": 1,
+ "\N{Ghost}": 2,
+ "\N{Skull and Crossbones}": 3,
+ "\N{Zombie}": 4,
+ "\N{Face Screaming In Fear}": 5,
+}
+ADDED_MESSAGES = [
+ "Let's see if you win?",
+ ":jack_o_lantern: SPOOKY :jack_o_lantern:",
+ "If you got it, haunt it.",
+ "TIME TO GET YOUR SPOOKY ON! :skull:",
+]
+PING = "<@{id}>"
+
+EMOJI_MESSAGE = "\n".join(f"- {emoji} {val}" for emoji, val in EMOJIS_VAL.items())
+HELP_MESSAGE_DICT = {
+ "title": "Spooky Name Rate",
+ "description": f"Help for the `{Client.prefix}spookynamerate` command",
+ "color": Colours.soft_orange,
+ "fields": [
+ {
+ "name": "How to play",
+ "value": (
+ "Everyday, the bot will post a random name, which you will need to spookify using your creativity.\n"
+ "You can rate each message according to how scary it is.\n"
+ "At the end of the day, the author of the message with most reactions will be the winner of the day.\n"
+ f"On a scale of 1 to {len(EMOJIS_VAL)}, the reactions order:\n"
+ f"{EMOJI_MESSAGE}"
+ ),
+ "inline": False,
+ },
+ {
+ "name": "How do I add my spookified name?",
+ "value": f"Simply type `{Client.prefix}spookynamerate add my name`",
+ "inline": False,
+ },
+ {
+ "name": "How do I *delete* my spookified name?",
+ "value": f"Simply type `{Client.prefix}spookynamerate delete`",
+ "inline": False,
+ },
+ ],
+}
+
+# The names are from https://www.mockaroo.com/
+NAMES = json.loads(Path("bot/resources/holidays/halloween/spookynamerate_names.json").read_text("utf8"))
+FIRST_NAMES = NAMES["first_names"]
+LAST_NAMES = NAMES["last_names"]
+
+
+class SpookyNameRate(Cog):
+ """
+ A game that asks the user to spookify or halloweenify a name that is given everyday.
+
+ It sends a random name everyday. The user needs to try and spookify it to his best ability and
+ send that name back using the `spookynamerate add entry` command
+ """
+
+ # This cache stores the message id of each added word along with a dictionary which contains the name the author
+ # added, the author's id, and the author's score (which is 0 by default)
+ messages = RedisCache()
+
+ # The data cache stores small information such as the current name that is going on and whether it is the first time
+ # the bot is running
+ data = RedisCache()
+ debug = getenv("SPOOKYNAMERATE_DEBUG", False) # Enable if you do not want to limit the commands to October or if
+ # you do not want to wait till 12 UTC. Note: if debug is enabled and you run `.cogs reload spookynamerate`, it
+ # will automatically start the scoring and announcing the result (without waiting for 12, so do not expect it to.).
+ # Also, it won't wait for the two hours (when the poll closes).
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.name = None
+
+ self.bot.loop.create_task(self.load_vars())
+
+ self.first_time = None
+ self.poll = False
+ self.announce_name.start()
+ self.checking_messages = asyncio.Lock()
+ # Define an asyncio.Lock() to make sure the dictionary isn't changed
+ # when checking the messages for duplicate emojis'
+
+ async def load_vars(self) -> None:
+ """Loads the variables that couldn't be loaded in __init__."""
+ self.first_time = await self.data.get("first_time", True)
+ self.name = await self.data.get("name")
+
+ @group(name="spookynamerate", invoke_without_command=True)
+ async def spooky_name_rate(self, ctx: Context) -> None:
+ """Get help on the Spooky Name Rate game."""
+ await ctx.send(embed=Embed.from_dict(HELP_MESSAGE_DICT))
+
+ @spooky_name_rate.command(name="list", aliases=("all", "entries"))
+ async def list_entries(self, ctx: Context) -> None:
+ """Send all the entries up till now in a single embed."""
+ await ctx.send(embed=await self.get_responses_list(final=False))
+
+ @spooky_name_rate.command(name="name")
+ async def tell_name(self, ctx: Context) -> None:
+ """Tell the current random name."""
+ if not self.poll:
+ await ctx.send(f"The name is **{self.name}**")
+ return
+
+ await ctx.send(
+ f"The name ~~is~~ was **{self.name}**. The poll has already started, so you cannot "
+ "add an entry."
+ )
+
+ @spooky_name_rate.command(name="add", aliases=("register",))
+ async def add_name(self, ctx: Context, *, name: str) -> None:
+ """Use this command to add/register your spookified name."""
+ if self.poll:
+ logger.info(f"{ctx.author} tried to add a name, but the poll had already started.")
+ await ctx.send("Sorry, the poll has started! You can try and participate in the next round though!")
+ return
+
+ for data in (json.loads(user_data) for _, user_data in await self.messages.items()):
+ if data["author"] == ctx.author.id:
+ await ctx.send(
+ "But you have already added an entry! Type "
+ f"`{self.bot.command_prefix}spookynamerate "
+ "delete` to delete it, and then you can add it again"
+ )
+ return
+
+ elif data["name"] == name:
+ await ctx.send("TOO LATE. Someone has already added this name.")
+ return
+
+ msg = await (await self.get_channel()).send(f"{ctx.author.mention} added the name {name!r}!")
+
+ await self.messages.set(
+ msg.id,
+ json.dumps(
+ {
+ "name": name,
+ "author": ctx.author.id,
+ "score": 0,
+ }
+ ),
+ )
+
+ for emoji in EMOJIS_VAL:
+ await msg.add_reaction(emoji)
+
+ logger.info(f"{ctx.author} added the name {name!r}")
+
+ @spooky_name_rate.command(name="delete")
+ async def delete_name(self, ctx: Context) -> None:
+ """Delete the user's name."""
+ if self.poll:
+ await ctx.send("You can't delete your name since the poll has already started!")
+ return
+ for message_id, data in await self.messages.items():
+ data = json.loads(data)
+
+ if ctx.author.id == data["author"]:
+ await self.messages.delete(message_id)
+ await ctx.send(f"Name deleted successfully ({data['name']!r})!")
+ return
+
+ await ctx.send(
+ f"But you don't have an entry... :eyes: Type `{self.bot.command_prefix}spookynamerate add your entry`"
+ )
+
+ @Cog.listener()
+ async def on_reaction_add(self, reaction: Reaction, user: User) -> None:
+ """Ensures that each user adds maximum one reaction."""
+ if user.bot or not await self.messages.contains(reaction.message.id):
+ return
+
+ async with self.checking_messages: # Acquire the lock so that the dictionary isn't reset while iterating.
+ if reaction.emoji in EMOJIS_VAL:
+ # create a custom counter
+ reaction_counter = defaultdict(int)
+ for msg_reaction in reaction.message.reactions:
+ async for reaction_user in msg_reaction.users():
+ if reaction_user == self.bot.user:
+ continue
+ reaction_counter[reaction_user] += 1
+
+ if reaction_counter[user] > 1:
+ await user.send(
+ "Sorry, you have already added a reaction, "
+ "please remove your reaction and try again."
+ )
+ await reaction.remove(user)
+ return
+
+ @tasks.loop(hours=24.0)
+ async def announce_name(self) -> None:
+ """Announces the name needed to spookify every 24 hours and the winner of the previous game."""
+ if not self.in_allowed_month():
+ return
+
+ channel = await self.get_channel()
+
+ if self.first_time:
+ await channel.send(
+ "Okkey... Welcome to the **Spooky Name Rate Game**! It's a relatively simple game.\n"
+ f"Everyday, a random name will be sent in <#{Channels.community_bot_commands}> "
+ "and you need to try and spookify it!\nRegister your name using "
+ f"`{self.bot.command_prefix}spookynamerate add spookified name`"
+ )
+
+ await self.data.set("first_time", False)
+ self.first_time = False
+
+ else:
+ if await self.messages.items():
+ await channel.send(embed=await self.get_responses_list(final=True))
+ self.poll = True
+ if not SpookyNameRate.debug:
+ await asyncio.sleep(2 * 60 * 60) # sleep for two hours
+
+ logger.info("Calculating score")
+ for message_id, data in await self.messages.items():
+ data = json.loads(data)
+
+ msg = await channel.fetch_message(message_id)
+ score = 0
+ for reaction in msg.reactions:
+ reaction_value = EMOJIS_VAL.get(reaction.emoji, 0) # get the value of the emoji else 0
+ score += reaction_value * (reaction.count - 1) # multiply by the num of reactions
+ # subtract one, since one reaction was done by the bot
+
+ logger.debug(f"{self.bot.get_user(data['author'])} got a score of {score}")
+ data["score"] = score
+ await self.messages.set(message_id, json.dumps(data))
+
+ # Sort the winner messages
+ winner_messages = sorted(
+ ((msg_id, json.loads(usr_data)) for msg_id, usr_data in await self.messages.items()),
+ key=lambda x: x[1]["score"],
+ reverse=True,
+ )
+
+ winners = []
+ for i, winner in enumerate(winner_messages):
+ winners.append(winner)
+ if len(winner_messages) > i + 1:
+ if winner_messages[i + 1][1]["score"] != winner[1]["score"]:
+ break
+ elif len(winner_messages) == (i + 1) + 1: # The next element is the last element
+ if winner_messages[i + 1][1]["score"] != winner[1]["score"]:
+ break
+
+ # one iteration is complete
+ await channel.send("Today's Spooky Name Rate Game ends now, and the winner(s) is(are)...")
+
+ async with channel.typing():
+ await asyncio.sleep(1) # give the drum roll feel
+
+ if not winners: # There are no winners (no participants)
+ await channel.send("Hmm... Looks like no one participated! :cry:")
+ return
+
+ score = winners[0][1]["score"]
+ congratulations = "to all" if len(winners) > 1 else PING.format(id=winners[0][1]["author"])
+ names = ", ".join(f'{win[1]["name"]} ({PING.format(id=win[1]["author"])})' for win in winners)
+
+ # display winners, their names and scores
+ await channel.send(
+ f"Congratulations {congratulations}!\n"
+ f"You have a score of {score}!\n"
+ f"Your name{ 's were' if len(winners) > 1 else 'was'}:\n{names}"
+ )
+
+ # Send random party emojis
+ party = (random.choice([":partying_face:", ":tada:"]) for _ in range(random.randint(1, 10)))
+ await channel.send(" ".join(party))
+
+ async with self.checking_messages: # Acquire the lock to delete the messages
+ await self.messages.clear() # reset the messages
+
+ # send the next name
+ self.name = f"{random.choice(FIRST_NAMES)} {random.choice(LAST_NAMES)}"
+ await self.data.set("name", self.name)
+
+ await channel.send(
+ "Let's move on to the next name!\nAnd the next name is...\n"
+ f"**{self.name}**!\nTry to spookify that... :smirk:"
+ )
+
+ self.poll = False # accepting responses
+
+ @announce_name.before_loop
+ async def wait_till_scheduled_time(self) -> None:
+ """Waits till the next day's 12PM if crossed it, otherwise waits till the same day's 12PM."""
+ if SpookyNameRate.debug:
+ return
+
+ now = datetime.utcnow()
+ if now.hour < 12:
+ twelve_pm = now.replace(hour=12, minute=0, second=0, microsecond=0)
+ time_left = twelve_pm - now
+ await asyncio.sleep(time_left.seconds)
+ return
+
+ tomorrow_12pm = now + timedelta(days=1)
+ tomorrow_12pm = tomorrow_12pm.replace(hour=12, minute=0, second=0, microsecond=0)
+ await asyncio.sleep((tomorrow_12pm - now).seconds)
+
+ async def get_responses_list(self, final: bool = False) -> Embed:
+ """Returns an embed containing the responses of the people."""
+ channel = await self.get_channel()
+
+ embed = Embed(color=Colour.red())
+
+ if await self.messages.items():
+ if final:
+ embed.title = "Spooky Name Rate is about to end!"
+ embed.description = (
+ "This Spooky Name Rate round is about to end in 2 hours! You can review "
+ "the entries below! Have you rated other's names?"
+ )
+ else:
+ embed.title = "All the spookified names!"
+ embed.description = "See a list of all the entries entered by everyone!"
+ else:
+ embed.title = "No one has added an entry yet..."
+
+ for message_id, data in await self.messages.items():
+ data = json.loads(data)
+
+ embed.add_field(
+ name=(self.bot.get_user(data["author"]) or await self.bot.fetch_user(data["author"])).name,
+ value=f"[{(data)['name']}](https://discord.com/channels/{Client.guild}/{channel.id}/{message_id})",
+ )
+
+ return embed
+
+ async def get_channel(self) -> Optional[TextChannel]:
+ """Gets the sir-lancebot-channel after waiting until ready."""
+ await self.bot.wait_until_ready()
+ channel = self.bot.get_channel(
+ Channels.community_bot_commands
+ ) or await self.bot.fetch_channel(Channels.community_bot_commands)
+ if not channel:
+ logger.warning("Bot is unable to get the #seasonalbot-commands channel. Please check the channel ID.")
+ return channel
+
+ @staticmethod
+ def in_allowed_month() -> bool:
+ """Returns whether running in the limited month."""
+ if SpookyNameRate.debug:
+ return True
+
+ if not Client.month_override:
+ return datetime.utcnow().month == Month.OCTOBER
+ return Client.month_override == Month.OCTOBER
+
+ def cog_check(self, ctx: Context) -> bool:
+ """A command to check whether the command is being called in October."""
+ if not self.in_allowed_month():
+ raise InMonthCheckFailure("You can only use these commands in October!")
+ return True
+
+ def cog_unload(self) -> None:
+ """Stops the announce_name task."""
+ self.announce_name.cancel()
+
+
+def setup(bot: Bot) -> None:
+ """Load the SpookyNameRate Cog."""
+ bot.add_cog(SpookyNameRate(bot))
diff --git a/bot/exts/holidays/halloween/spookyrating.py b/bot/exts/holidays/halloween/spookyrating.py
new file mode 100644
index 00000000..ec6e8821
--- /dev/null
+++ b/bot/exts/holidays/halloween/spookyrating.py
@@ -0,0 +1,67 @@
+import bisect
+import json
+import logging
+import random
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+data: dict[str, dict[str, str]] = json.loads(
+ Path("bot/resources/holidays/halloween/spooky_rating.json").read_text("utf8")
+)
+SPOOKY_DATA = sorted((int(key), value) for key, value in data.items())
+
+
+class SpookyRating(commands.Cog):
+ """A cog for calculating one's spooky rating."""
+
+ def __init__(self):
+ self.local_random = random.Random()
+
+ @commands.command()
+ @commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
+ async def spookyrating(self, ctx: commands.Context, who: discord.Member = None) -> None:
+ """
+ Calculates the spooky rating of someone.
+
+ Any user will always yield the same result, no matter who calls the command
+ """
+ if who is None:
+ who = ctx.author
+
+ # This ensures that the same result over multiple runtimes
+ self.local_random.seed(who.id)
+ spooky_percent = self.local_random.randint(1, 101)
+
+ # We need the -1 due to how bisect returns the point
+ # see the documentation for further detail
+ # https://docs.python.org/3/library/bisect.html#bisect.bisect
+ index = bisect.bisect(SPOOKY_DATA, (spooky_percent,)) - 1
+
+ _, data = SPOOKY_DATA[index]
+
+ embed = discord.Embed(
+ title=data["title"],
+ description=f"{who} scored {spooky_percent}%!",
+ color=Colours.orange
+ )
+ embed.add_field(
+ name="A whisper from Satan",
+ value=data["text"]
+ )
+ embed.set_thumbnail(
+ url=data["image"]
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Spooky Rating Cog."""
+ bot.add_cog(SpookyRating())
diff --git a/bot/exts/holidays/halloween/spookyreact.py b/bot/exts/holidays/halloween/spookyreact.py
new file mode 100644
index 00000000..25e783f4
--- /dev/null
+++ b/bot/exts/holidays/halloween/spookyreact.py
@@ -0,0 +1,70 @@
+import logging
+import re
+
+import discord
+from discord.ext.commands import Cog
+
+from bot.bot import Bot
+from bot.constants import Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+SPOOKY_TRIGGERS = {
+ "spooky": (r"\bspo{2,}ky\b", "\U0001F47B"),
+ "skeleton": (r"\bskeleton\b", "\U0001F480"),
+ "doot": (r"\bdo{2,}t\b", "\U0001F480"),
+ "pumpkin": (r"\bpumpkin\b", "\U0001F383"),
+ "halloween": (r"\bhalloween\b", "\U0001F383"),
+ "jack-o-lantern": (r"\bjack-o-lantern\b", "\U0001F383"),
+ "danger": (r"\bdanger\b", "\U00002620")
+}
+
+
+class SpookyReact(Cog):
+ """A cog that makes the bot react to message triggers."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @in_month(Month.OCTOBER)
+ @Cog.listener()
+ async def on_message(self, message: discord.Message) -> None:
+ """Triggered when the bot sees a message in October."""
+ for name, trigger in SPOOKY_TRIGGERS.items():
+ trigger_test = re.search(trigger[0], message.content.lower())
+ if trigger_test:
+ # Check message for bot replies and/or command invocations
+ # Short circuit if they're found, logging is handled in _short_circuit_check
+ if await self._short_circuit_check(message):
+ return
+ else:
+ await message.add_reaction(trigger[1])
+ log.info(f"Added {name!r} reaction to message ID: {message.id}")
+
+ async def _short_circuit_check(self, message: discord.Message) -> bool:
+ """
+ Short-circuit helper check.
+
+ Return True if:
+ * author is the bot
+ * prefix is not None
+ """
+ # Check for self reaction
+ if message.author == self.bot.user:
+ log.debug(f"Ignoring reactions on self message. Message ID: {message.id}")
+ return True
+
+ # Check for command invocation
+ # Because on_message doesn't give a full Context object, generate one first
+ ctx = await self.bot.get_context(message)
+ if ctx.prefix:
+ log.debug(f"Ignoring reactions on command invocation. Message ID: {message.id}")
+ return True
+
+ return False
+
+
+def setup(bot: Bot) -> None:
+ """Load the Spooky Reaction Cog."""
+ bot.add_cog(SpookyReact(bot))
diff --git a/bot/exts/holidays/hanukkah/__init__.py b/bot/exts/holidays/hanukkah/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/hanukkah/__init__.py
diff --git a/bot/exts/holidays/hanukkah/hanukkah_embed.py b/bot/exts/holidays/hanukkah/hanukkah_embed.py
new file mode 100644
index 00000000..00125be3
--- /dev/null
+++ b/bot/exts/holidays/hanukkah/hanukkah_embed.py
@@ -0,0 +1,113 @@
+import datetime
+import logging
+
+from discord import Embed
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours, Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+HEBCAL_URL = (
+ "https://www.hebcal.com/hebcal/?v=1&cfg=json&maj=on&min=on&mod=on&nx=on&"
+ "year=now&month=x&ss=on&mf=on&c=on&geo=geoname&geonameid=3448439&m=50&s=on"
+)
+
+
+class HanukkahEmbed(commands.Cog):
+ """A cog that returns information about Hanukkah festival."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.hanukkah_days = []
+ self.hanukkah_months = []
+ self.hanukkah_years = []
+
+ async def get_hanukkah_dates(self) -> list[str]:
+ """Gets the dates for hanukkah festival."""
+ hanukkah_dates = []
+ async with self.bot.http_session.get(HEBCAL_URL) as response:
+ json_data = await response.json()
+ festivals = json_data["items"]
+ for festival in festivals:
+ if festival["title"].startswith("Chanukah"):
+ date = festival["date"]
+ hanukkah_dates.append(date)
+ return hanukkah_dates
+
+ @in_month(Month.DECEMBER)
+ @commands.command(name="hanukkah", aliases=("chanukah",))
+ async def hanukkah_festival(self, ctx: commands.Context) -> None:
+ """Tells you about the Hanukkah Festivaltime of festival, festival day, etc)."""
+ hanukkah_dates = await self.get_hanukkah_dates()
+ self.hanukkah_dates_split(hanukkah_dates)
+ hanukkah_start_day = int(self.hanukkah_days[0])
+ hanukkah_start_month = int(self.hanukkah_months[0])
+ hanukkah_start_year = int(self.hanukkah_years[0])
+ hanukkah_end_day = int(self.hanukkah_days[8])
+ hanukkah_end_month = int(self.hanukkah_months[8])
+ hanukkah_end_year = int(self.hanukkah_years[8])
+
+ hanukkah_start = datetime.date(hanukkah_start_year, hanukkah_start_month, hanukkah_start_day)
+ hanukkah_end = datetime.date(hanukkah_end_year, hanukkah_end_month, hanukkah_end_day)
+ today = datetime.date.today()
+ # today = datetime.date(2019, 12, 24) (for testing)
+ day = str(today.day)
+ month = str(today.month)
+ year = str(today.year)
+ embed = Embed(title="Hanukkah", colour=Colours.blue)
+ if day in self.hanukkah_days and month in self.hanukkah_months and year in self.hanukkah_years:
+ if int(day) == hanukkah_start_day:
+ now = datetime.datetime.utcnow()
+ hours = now.hour + 4 # using only hours
+ hanukkah_start_hour = 18
+ if hours < hanukkah_start_hour:
+ embed.description = (
+ "Hanukkah hasnt started yet, "
+ f"it will start in about {hanukkah_start_hour - hours} hour/s."
+ )
+ await ctx.send(embed=embed)
+ return
+ elif hours > hanukkah_start_hour:
+ embed.description = (
+ "It is the starting day of Hanukkah! "
+ f"Its been {hours - hanukkah_start_hour} hours hanukkah started!"
+ )
+ await ctx.send(embed=embed)
+ return
+ festival_day = self.hanukkah_days.index(day)
+ number_suffixes = ["st", "nd", "rd", "th"]
+ suffix = number_suffixes[festival_day - 1 if festival_day <= 3 else 3]
+ message = ":menorah:" * festival_day
+ embed.description = f"It is the {festival_day}{suffix} day of Hanukkah!\n{message}"
+ await ctx.send(embed=embed)
+ else:
+ if today < hanukkah_start:
+ festival_starting_month = hanukkah_start.strftime("%B")
+ embed.description = (
+ f"Hanukkah has not started yet. "
+ f"Hanukkah will start at sundown on {hanukkah_start_day}th "
+ f"of {festival_starting_month}."
+ )
+ else:
+ festival_end_month = hanukkah_end.strftime("%B")
+ embed.description = (
+ f"Looks like you missed Hanukkah!"
+ f"Hanukkah ended on {hanukkah_end_day}th of {festival_end_month}."
+ )
+
+ await ctx.send(embed=embed)
+
+ def hanukkah_dates_split(self, hanukkah_dates: list[str]) -> None:
+ """We are splitting the dates for hanukkah into days, months and years."""
+ for date in hanukkah_dates:
+ self.hanukkah_days.append(date[8:10])
+ self.hanukkah_months.append(date[5:7])
+ self.hanukkah_years.append(date[0:4])
+
+
+def setup(bot: Bot) -> None:
+ """Load the Hanukkah Embed Cog."""
+ bot.add_cog(HanukkahEmbed(bot))
diff --git a/bot/exts/holidays/pride/__init__.py b/bot/exts/holidays/pride/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/pride/__init__.py
diff --git a/bot/exts/holidays/pride/drag_queen_name.py b/bot/exts/holidays/pride/drag_queen_name.py
new file mode 100644
index 00000000..bd01a603
--- /dev/null
+++ b/bot/exts/holidays/pride/drag_queen_name.py
@@ -0,0 +1,26 @@
+import json
+import logging
+import random
+from pathlib import Path
+
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+NAMES = json.loads(Path("bot/resources/holidays/pride/drag_queen_names.json").read_text("utf8"))
+
+
+class DragNames(commands.Cog):
+ """Gives a random drag queen name!"""
+
+ @commands.command(name="dragname", aliases=("dragqueenname", "queenme"))
+ async def dragname(self, ctx: commands.Context) -> None:
+ """Sends a message with a drag queen name."""
+ await ctx.send(random.choice(NAMES))
+
+
+def setup(bot: Bot) -> None:
+ """Load the Drag Names Cog."""
+ bot.add_cog(DragNames())
diff --git a/bot/exts/holidays/pride/pride_anthem.py b/bot/exts/holidays/pride/pride_anthem.py
new file mode 100644
index 00000000..e8a4563b
--- /dev/null
+++ b/bot/exts/holidays/pride/pride_anthem.py
@@ -0,0 +1,51 @@
+import json
+import logging
+import random
+from pathlib import Path
+from typing import Optional
+
+from discord.ext import commands
+
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+VIDEOS = json.loads(Path("bot/resources/holidays/pride/anthems.json").read_text("utf8"))
+
+
+class PrideAnthem(commands.Cog):
+ """Embed a random youtube video for a gay anthem!"""
+
+ def get_video(self, genre: Optional[str] = None) -> dict:
+ """
+ Picks a random anthem from the list.
+
+ If `genre` is supplied, it will pick from videos attributed with that genre.
+ If none can be found, it will log this as well as provide that information to the user.
+ """
+ if not genre:
+ return random.choice(VIDEOS)
+ else:
+ songs = [song for song in VIDEOS if genre.casefold() in song["genre"]]
+ try:
+ return random.choice(songs)
+ except IndexError:
+ log.info("No videos for that genre.")
+
+ @commands.command(name="prideanthem", aliases=("anthem", "pridesong"))
+ async def prideanthem(self, ctx: commands.Context, genre: str = None) -> None:
+ """
+ Sends a message with a video of a random pride anthem.
+
+ If `genre` is supplied, it will select from that genre only.
+ """
+ anthem = self.get_video(genre)
+ if anthem:
+ await ctx.send(anthem["url"])
+ else:
+ await ctx.send("I couldn't find a video, sorry!")
+
+
+def setup(bot: Bot) -> None:
+ """Load the Pride Anthem Cog."""
+ bot.add_cog(PrideAnthem())
diff --git a/bot/exts/holidays/pride/pride_facts.py b/bot/exts/holidays/pride/pride_facts.py
new file mode 100644
index 00000000..e6ef7108
--- /dev/null
+++ b/bot/exts/holidays/pride/pride_facts.py
@@ -0,0 +1,99 @@
+import json
+import logging
+import random
+from datetime import datetime
+from pathlib import Path
+from typing import Union
+
+import dateutil.parser
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Channels, Colours, Month
+from bot.utils.decorators import seasonal_task
+
+log = logging.getLogger(__name__)
+
+FACTS = json.loads(Path("bot/resources/holidays/pride/facts.json").read_text("utf8"))
+
+
+class PrideFacts(commands.Cog):
+ """Provides a new fact every day during the Pride season!"""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.daily_fact_task = self.bot.loop.create_task(self.send_pride_fact_daily())
+
+ @seasonal_task(Month.JUNE)
+ async def send_pride_fact_daily(self) -> None:
+ """Background task to post the daily pride fact every day."""
+ await self.bot.wait_until_guild_available()
+
+ channel = self.bot.get_channel(Channels.community_bot_commands)
+ await self.send_select_fact(channel, datetime.utcnow())
+
+ async def send_random_fact(self, ctx: commands.Context) -> None:
+ """Provides a fact from any previous day, or today."""
+ now = datetime.utcnow()
+ previous_years_facts = (y for x, y in FACTS.items() if int(x) < now.year)
+ current_year_facts = FACTS.get(str(now.year), [])[:now.day]
+ previous_facts = current_year_facts + [x for y in previous_years_facts for x in y]
+ try:
+ await ctx.send(embed=self.make_embed(random.choice(previous_facts)))
+ except IndexError:
+ await ctx.send("No facts available")
+
+ async def send_select_fact(self, target: discord.abc.Messageable, _date: Union[str, datetime]) -> None:
+ """Provides the fact for the specified day, if the day is today, or is in the past."""
+ now = datetime.utcnow()
+ if isinstance(_date, str):
+ try:
+ date = dateutil.parser.parse(_date, dayfirst=False, yearfirst=False, fuzzy=True)
+ except (ValueError, OverflowError) as err:
+ await target.send(f"Error parsing date: {err}")
+ return
+ else:
+ date = _date
+ if date.year < now.year or (date.year == now.year and date.day <= now.day):
+ try:
+ await target.send(embed=self.make_embed(FACTS[str(date.year)][date.day - 1]))
+ except KeyError:
+ await target.send(f"The year {date.year} is not yet supported")
+ return
+ except IndexError:
+ await target.send(f"Day {date.day} of {date.year} is not yet support")
+ return
+ else:
+ await target.send("The fact for the selected day is not yet available.")
+
+ @commands.command(name="pridefact", aliases=("pridefacts",))
+ async def pridefact(self, ctx: commands.Context, option: str = None) -> None:
+ """
+ Sends a message with a pride fact of the day.
+
+ If "random" is given as an argument, a random previous fact will be provided.
+
+ If a date is given as an argument, and the date is in the past, the fact from that day
+ will be provided.
+ """
+ if not option:
+ await self.send_select_fact(ctx, datetime.utcnow())
+ elif option.lower().startswith("rand"):
+ await self.send_random_fact(ctx)
+ else:
+ await self.send_select_fact(ctx, option)
+
+ @staticmethod
+ def make_embed(fact: str) -> discord.Embed:
+ """Makes a nice embed for the fact to be sent."""
+ return discord.Embed(
+ colour=Colours.pink,
+ title="Pride Fact!",
+ description=fact
+ )
+
+
+def setup(bot: Bot) -> None:
+ """Load the Pride Facts Cog."""
+ bot.add_cog(PrideFacts(bot))
diff --git a/bot/exts/holidays/pride/pride_leader.py b/bot/exts/holidays/pride/pride_leader.py
new file mode 100644
index 00000000..298c9328
--- /dev/null
+++ b/bot/exts/holidays/pride/pride_leader.py
@@ -0,0 +1,117 @@
+import json
+import logging
+import random
+from pathlib import Path
+from typing import Optional
+
+import discord
+from discord.ext import commands
+from rapidfuzz import fuzz
+
+from bot import constants
+from bot.bot import Bot
+
+log = logging.getLogger(__name__)
+
+PRIDE_RESOURCE = json.loads(Path("bot/resources/holidays/pride/prideleader.json").read_text("utf8"))
+MINIMUM_FUZZ_RATIO = 40
+
+
+class PrideLeader(commands.Cog):
+ """Gives information about Pride Leaders."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ def invalid_embed_generate(self, pride_leader: str) -> discord.Embed:
+ """
+ Generates Invalid Embed.
+
+ The invalid embed contains a list of closely matched names of the invalid pride
+ leader the user gave. If no closely matched names are found it would list all
+ the available pride leader names.
+
+ Wikipedia is a useful place to learn about pride leaders and we don't have all
+ the pride leaders, so the bot would add a field containing the wikipedia
+ command to execute.
+ """
+ embed = discord.Embed(
+ color=constants.Colours.soft_red
+ )
+ valid_names = []
+ pride_leader = pride_leader.title()
+ for name in PRIDE_RESOURCE:
+ if fuzz.ratio(pride_leader, name) >= MINIMUM_FUZZ_RATIO:
+ valid_names.append(name)
+
+ if not valid_names:
+ valid_names = ", ".join(PRIDE_RESOURCE)
+ error_msg = "Sorry your input didn't match any stored names, here is a list of available names:"
+ else:
+ valid_names = "\n".join(valid_names)
+ error_msg = "Did you mean?"
+
+ embed.description = f"{error_msg}\n```\n{valid_names}\n```"
+ embed.set_footer(text="To add more pride leaders, feel free to open a pull request!")
+
+ return embed
+
+ def embed_builder(self, pride_leader: dict) -> discord.Embed:
+ """Generate an Embed with information about a pride leader."""
+ name = [name for name, info in PRIDE_RESOURCE.items() if info == pride_leader][0]
+
+ embed = discord.Embed(
+ title=name,
+ description=pride_leader["About"],
+ color=constants.Colours.blue
+ )
+ embed.add_field(
+ name="Known for",
+ value=pride_leader["Known for"],
+ inline=False
+ )
+ embed.add_field(
+ name="D.O.B and Birth place",
+ value=pride_leader["Born"],
+ inline=False
+ )
+ embed.add_field(
+ name="Awards and honors",
+ value=pride_leader["Awards"],
+ inline=False
+ )
+ embed.add_field(
+ name="For More Information",
+ value=f"Do `{constants.Client.prefix}wiki {name}`"
+ f" in <#{constants.Channels.community_bot_commands}>",
+ inline=False
+ )
+ embed.set_thumbnail(url=pride_leader["url"])
+ return embed
+
+ @commands.command(aliases=("pl", "prideleader"))
+ async def pride_leader(self, ctx: commands.Context, *, pride_leader_name: Optional[str]) -> None:
+ """
+ Information about a Pride Leader.
+
+ Returns information about the specified pride leader
+ and if there is no pride leader given, return a random pride leader.
+ """
+ if not pride_leader_name:
+ leader = random.choice(list(PRIDE_RESOURCE.values()))
+ else:
+ leader = PRIDE_RESOURCE.get(pride_leader_name.title())
+ if not leader:
+ log.trace(f"Got a Invalid pride leader: {pride_leader_name}")
+
+ embed = self.invalid_embed_generate(pride_leader_name)
+ await ctx.send(embed=embed)
+ return
+
+ embed = self.embed_builder(leader)
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Pride Leader Cog."""
+ bot.add_cog(PrideLeader(bot))
diff --git a/bot/exts/holidays/valentines/__init__.py b/bot/exts/holidays/valentines/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/bot/exts/holidays/valentines/__init__.py
diff --git a/bot/exts/holidays/valentines/be_my_valentine.py b/bot/exts/holidays/valentines/be_my_valentine.py
new file mode 100644
index 00000000..4d454c3a
--- /dev/null
+++ b/bot/exts/holidays/valentines/be_my_valentine.py
@@ -0,0 +1,192 @@
+import logging
+import random
+from json import loads
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Channels, Colours, Lovefest, Month
+from bot.utils.decorators import in_month
+from bot.utils.extensions import invoke_help_command
+
+log = logging.getLogger(__name__)
+
+HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"]
+
+
+class BeMyValentine(commands.Cog):
+ """A cog that sends Valentines to other users!"""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+ self.valentines = self.load_json()
+
+ @staticmethod
+ def load_json() -> dict:
+ """Load Valentines messages from the static resources."""
+ p = Path("bot/resources/holidays/valentines/bemyvalentine_valentines.json")
+ return loads(p.read_text("utf8"))
+
+ @in_month(Month.FEBRUARY)
+ @commands.group(name="lovefest")
+ async def lovefest_role(self, ctx: commands.Context) -> None:
+ """
+ Subscribe or unsubscribe from the lovefest role.
+
+ The lovefest role makes you eligible to receive anonymous valentines from other users.
+
+ 1) use the command \".lovefest sub\" to get the lovefest role.
+ 2) use the command \".lovefest unsub\" to get rid of the lovefest role.
+ """
+ if not ctx.invoked_subcommand:
+ await invoke_help_command(ctx)
+
+ @lovefest_role.command(name="sub")
+ async def add_role(self, ctx: commands.Context) -> None:
+ """Adds the lovefest role."""
+ user = ctx.author
+ role = ctx.guild.get_role(Lovefest.role_id)
+ if role not in ctx.author.roles:
+ await user.add_roles(role)
+ await ctx.send("The Lovefest role has been added !")
+ else:
+ await ctx.send("You already have the role !")
+
+ @lovefest_role.command(name="unsub")
+ async def remove_role(self, ctx: commands.Context) -> None:
+ """Removes the lovefest role."""
+ user = ctx.author
+ role = ctx.guild.get_role(Lovefest.role_id)
+ if role not in ctx.author.roles:
+ await ctx.send("You dont have the lovefest role.")
+ else:
+ await user.remove_roles(role)
+ await ctx.send("The lovefest role has been successfully removed!")
+
+ @commands.cooldown(1, 1800, commands.BucketType.user)
+ @commands.group(name="bemyvalentine", invoke_without_command=True)
+ async def send_valentine(
+ self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None
+ ) -> None:
+ """
+ Send a valentine to a specified user with the lovefest role.
+
+ syntax: .bemyvalentine [user] [p/poem/c/compliment/or you can type your own valentine message]
+ (optional)
+
+ example: .bemyvalentine Iceman#6508 p (sends a poem to Iceman)
+ example: .bemyvalentine Iceman Hey I love you, wanna hang around ? (sends the custom message to Iceman)
+ NOTE : AVOID TAGGING THE USER MOST OF THE TIMES.JUST TRIM THE '@' when using this command.
+ """
+ if ctx.guild is None:
+ # This command should only be used in the server
+ raise commands.UserInputError("You are supposed to use this command in the server.")
+
+ if Lovefest.role_id not in [role.id for role in user.roles]:
+ raise commands.UserInputError(
+ f"You cannot send a valentine to {user} as they do not have the lovefest role!"
+ )
+
+ if user == ctx.author:
+ # Well a user can't valentine himself/herself.
+ raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:")
+
+ emoji_1, emoji_2 = self.random_emoji()
+ channel = self.bot.get_channel(Channels.community_bot_commands)
+ valentine, title = self.valentine_check(valentine_type)
+
+ embed = discord.Embed(
+ title=f"{emoji_1} {title} {user.display_name} {emoji_2}",
+ description=f"{valentine} \n **{emoji_2}From {ctx.author}{emoji_1}**",
+ color=Colours.pink
+ )
+ await channel.send(user.mention, embed=embed)
+
+ @commands.cooldown(1, 1800, commands.BucketType.user)
+ @send_valentine.command(name="secret")
+ async def anonymous(
+ self, ctx: commands.Context, user: discord.Member, *, valentine_type: str = None
+ ) -> None:
+ """
+ Send an anonymous Valentine via DM to to a specified user with the lovefest role.
+
+ syntax : .bemyvalentine secret [user] [p/poem/c/compliment/or you can type your own valentine message]
+ (optional)
+
+ example : .bemyvalentine secret Iceman#6508 p (sends a poem to Iceman in DM making you anonymous)
+ example : .bemyvalentine secret Iceman#6508 Hey I love you, wanna hang around ? (sends the custom message to
+ Iceman in DM making you anonymous)
+ """
+ if Lovefest.role_id not in [role.id for role in user.roles]:
+ await ctx.message.delete()
+ raise commands.UserInputError(
+ f"You cannot send a valentine to {user} as they do not have the lovefest role!"
+ )
+
+ if user == ctx.author:
+ # Well a user cant valentine himself/herself.
+ raise commands.UserInputError("Come on, you can't send a valentine to yourself :expressionless:")
+
+ emoji_1, emoji_2 = self.random_emoji()
+ valentine, title = self.valentine_check(valentine_type)
+
+ embed = discord.Embed(
+ title=f"{emoji_1}{title} {user.display_name}{emoji_2}",
+ description=f"{valentine} \n **{emoji_2}From anonymous{emoji_1}**",
+ color=Colours.pink
+ )
+ await ctx.message.delete()
+ try:
+ await user.send(embed=embed)
+ except discord.Forbidden:
+ raise commands.UserInputError(f"{user} has DMs disabled, so I couldn't send the message. Sorry!")
+ else:
+ await ctx.author.send(f"Your message has been sent to {user}")
+
+ def valentine_check(self, valentine_type: str) -> tuple[str, str]:
+ """Return the appropriate Valentine type & title based on the invoking user's input."""
+ if valentine_type is None:
+ return self.random_valentine()
+
+ elif valentine_type.lower() in ["p", "poem"]:
+ return self.valentine_poem(), "A poem dedicated to"
+
+ elif valentine_type.lower() in ["c", "compliment"]:
+ return self.valentine_compliment(), "A compliment for"
+
+ else:
+ # in this case, the user decides to type his own valentine.
+ return valentine_type, "A message for"
+
+ @staticmethod
+ def random_emoji() -> tuple[str, str]:
+ """Return two random emoji from the module-defined constants."""
+ emoji_1 = random.choice(HEART_EMOJIS)
+ emoji_2 = random.choice(HEART_EMOJIS)
+ return emoji_1, emoji_2
+
+ def random_valentine(self) -> tuple[str, str]:
+ """Grabs a random poem or a compliment (any message)."""
+ valentine_poem = random.choice(self.valentines["valentine_poems"])
+ valentine_compliment = random.choice(self.valentines["valentine_compliments"])
+ random_valentine = random.choice([valentine_compliment, valentine_poem])
+ if random_valentine == valentine_poem:
+ title = "A poem dedicated to"
+ else:
+ title = "A compliment for "
+ return random_valentine, title
+
+ def valentine_poem(self) -> str:
+ """Grabs a random poem."""
+ return random.choice(self.valentines["valentine_poems"])
+
+ def valentine_compliment(self) -> str:
+ """Grabs a random compliment."""
+ return random.choice(self.valentines["valentine_compliments"])
+
+
+def setup(bot: Bot) -> None:
+ """Load the Be my Valentine Cog."""
+ bot.add_cog(BeMyValentine(bot))
diff --git a/bot/exts/holidays/valentines/lovecalculator.py b/bot/exts/holidays/valentines/lovecalculator.py
new file mode 100644
index 00000000..3999db2b
--- /dev/null
+++ b/bot/exts/holidays/valentines/lovecalculator.py
@@ -0,0 +1,99 @@
+import bisect
+import hashlib
+import json
+import logging
+import random
+from pathlib import Path
+from typing import Coroutine, Optional
+
+import discord
+from discord import Member
+from discord.ext import commands
+from discord.ext.commands import BadArgument, Cog, clean_content
+
+from bot.bot import Bot
+from bot.constants import Channels, Client, Lovefest, Month
+from bot.utils.decorators import in_month
+
+log = logging.getLogger(__name__)
+
+LOVE_DATA = json.loads(Path("bot/resources/holidays/valentines/love_matches.json").read_text("utf8"))
+LOVE_DATA = sorted((int(key), value) for key, value in LOVE_DATA.items())
+
+
+class LoveCalculator(Cog):
+ """A cog for calculating the love between two people."""
+
+ @in_month(Month.FEBRUARY)
+ @commands.command(aliases=("love_calculator", "love_calc"))
+ @commands.cooldown(rate=1, per=5, type=commands.BucketType.user)
+ async def love(self, ctx: commands.Context, who: Member, whom: Optional[Member] = None) -> None:
+ """
+ Tells you how much the two love each other.
+
+ This command requires at least one member as input, if two are given love will be calculated between
+ those two users, if only one is given, the second member is asusmed to be the invoker.
+ Members are converted from:
+ - User ID
+ - Mention
+ - name#discrim
+ - name
+ - nickname
+
+ Any two arguments will always yield the same result, regardless of the order of arguments:
+ Running .love @joe#6000 @chrisjl#2655 will always yield the same result.
+ Running .love @chrisjl#2655 @joe#6000 will yield the same result as before.
+ """
+ if (
+ Lovefest.role_id not in [role.id for role in who.roles]
+ or (whom is not None and Lovefest.role_id not in [role.id for role in whom.roles])
+ ):
+ raise BadArgument(
+ "This command can only be ran against members with the lovefest role! "
+ "This role be can assigned by running "
+ f"`{Client.prefix}lovefest sub` in <#{Channels.community_bot_commands}>."
+ )
+
+ if whom is None:
+ whom = ctx.author
+
+ def normalize(arg: Member) -> Coroutine:
+ # This has to be done manually to be applied to usernames
+ return clean_content(escape_markdown=True).convert(ctx, str(arg))
+
+ # Sort to ensure same result for same input, regardless of order
+ who, whom = sorted([await normalize(arg) for arg in (who, whom)])
+
+ # Hash inputs to guarantee consistent results (hashing algorithm choice arbitrary)
+ #
+ # hashlib is used over the builtin hash() to guarantee same result over multiple runtimes
+ m = hashlib.sha256(who.encode() + whom.encode())
+ # Mod 101 for [0, 100]
+ love_percent = sum(m.digest()) % 101
+
+ # We need the -1 due to how bisect returns the point
+ # see the documentation for further detail
+ # https://docs.python.org/3/library/bisect.html#bisect.bisect
+ index = bisect.bisect(LOVE_DATA, (love_percent,)) - 1
+ # We already have the nearest "fit" love level
+ # We only need the dict, so we can ditch the first element
+ _, data = LOVE_DATA[index]
+
+ status = random.choice(data["titles"])
+ embed = discord.Embed(
+ title=status,
+ description=f"{who} \N{HEAVY BLACK HEART} {whom} scored {love_percent}%!\n\u200b",
+ color=discord.Color.dark_magenta()
+ )
+ embed.add_field(
+ name="A letter from Dr. Love:",
+ value=data["text"]
+ )
+ embed.set_footer(text=f"You can unsubscribe from lovefest by using {Client.prefix}lovefest unsub")
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Love calculator Cog."""
+ bot.add_cog(LoveCalculator())
diff --git a/bot/exts/holidays/valentines/movie_generator.py b/bot/exts/holidays/valentines/movie_generator.py
new file mode 100644
index 00000000..d2dc8213
--- /dev/null
+++ b/bot/exts/holidays/valentines/movie_generator.py
@@ -0,0 +1,67 @@
+import logging
+import random
+from os import environ
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+
+TMDB_API_KEY = environ.get("TMDB_API_KEY")
+
+log = logging.getLogger(__name__)
+
+
+class RomanceMovieFinder(commands.Cog):
+ """A Cog that returns a random romance movie suggestion to a user."""
+
+ def __init__(self, bot: Bot):
+ self.bot = bot
+
+ @commands.command(name="romancemovie")
+ async def romance_movie(self, ctx: commands.Context) -> None:
+ """Randomly selects a romance movie and displays information about it."""
+ # Selecting a random int to parse it to the page parameter
+ random_page = random.randint(0, 20)
+ # TMDB api params
+ params = {
+ "api_key": TMDB_API_KEY,
+ "language": "en-US",
+ "sort_by": "popularity.desc",
+ "include_adult": "false",
+ "include_video": "false",
+ "page": random_page,
+ "with_genres": "10749"
+ }
+ # The api request url
+ request_url = "https://api.themoviedb.org/3/discover/movie"
+ async with self.bot.http_session.get(request_url, params=params) as resp:
+ # Trying to load the json file returned from the api
+ try:
+ data = await resp.json()
+ # Selecting random result from results object in the json file
+ selected_movie = random.choice(data["results"])
+
+ embed = discord.Embed(
+ title=f":sparkling_heart: {selected_movie['title']} :sparkling_heart:",
+ description=selected_movie["overview"],
+ )
+ embed.set_image(url=f"http://image.tmdb.org/t/p/w200/{selected_movie['poster_path']}")
+ embed.add_field(name="Release date :clock1:", value=selected_movie["release_date"])
+ embed.add_field(name="Rating :star2:", value=selected_movie["vote_average"])
+ embed.set_footer(text="This product uses the TMDb API but is not endorsed or certified by TMDb.")
+ embed.set_thumbnail(url="https://i.imgur.com/LtFtC8H.png")
+ await ctx.send(embed=embed)
+ except KeyError:
+ warning_message = (
+ "A KeyError was raised while fetching information on the movie. The API service"
+ " could be unavailable or the API key could be set incorrectly."
+ )
+ embed = discord.Embed(title=warning_message)
+ log.warning(warning_message)
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Romance movie Cog."""
+ bot.add_cog(RomanceMovieFinder(bot))
diff --git a/bot/exts/holidays/valentines/myvalenstate.py b/bot/exts/holidays/valentines/myvalenstate.py
new file mode 100644
index 00000000..4b547d9b
--- /dev/null
+++ b/bot/exts/holidays/valentines/myvalenstate.py
@@ -0,0 +1,82 @@
+import collections
+import json
+import logging
+from pathlib import Path
+from random import choice
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+STATES = json.loads(Path("bot/resources/holidays/valentines/valenstates.json").read_text("utf8"))
+
+
+class MyValenstate(commands.Cog):
+ """A Cog to find your most likely Valentine's vacation destination."""
+
+ def levenshtein(self, source: str, goal: str) -> int:
+ """Calculates the Levenshtein Distance between source and goal."""
+ if len(source) < len(goal):
+ return self.levenshtein(goal, source)
+ if len(source) == 0:
+ return len(goal)
+ if len(goal) == 0:
+ return len(source)
+
+ pre_row = list(range(0, len(source) + 1))
+ for i, source_c in enumerate(source):
+ cur_row = [i + 1]
+ for j, goal_c in enumerate(goal):
+ if source_c != goal_c:
+ cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]) + 1)
+ else:
+ cur_row.append(min(pre_row[j], pre_row[j + 1], cur_row[j]))
+ pre_row = cur_row
+ return pre_row[-1]
+
+ @commands.command()
+ async def myvalenstate(self, ctx: commands.Context, *, name: str = None) -> None:
+ """Find the vacation spot(s) with the most matching characters to the invoking user."""
+ eq_chars = collections.defaultdict(int)
+ if name is None:
+ author = ctx.author.name.lower().replace(" ", "")
+ else:
+ author = name.lower().replace(" ", "")
+
+ for state in STATES.keys():
+ lower_state = state.lower().replace(" ", "")
+ eq_chars[state] = self.levenshtein(author, lower_state)
+
+ matches = [x for x, y in eq_chars.items() if y == min(eq_chars.values())]
+ valenstate = choice(matches)
+ matches.remove(valenstate)
+
+ embed_title = "But there are more!"
+ if len(matches) > 1:
+ leftovers = f"{', '.join(matches[:-2])}, and {matches[-1]}"
+ embed_text = f"You have {len(matches)} more matches, these being {leftovers}."
+ elif len(matches) == 1:
+ embed_title = "But there's another one!"
+ embed_text = f"You have another match, this being {matches[0]}."
+ else:
+ embed_title = "You have a true match!"
+ embed_text = "This state is your true Valenstate! There are no states that would suit" \
+ " you better"
+
+ embed = discord.Embed(
+ title=f"Your Valenstate is {valenstate} \u2764",
+ description=STATES[valenstate]["text"],
+ colour=Colours.pink
+ )
+ embed.add_field(name=embed_title, value=embed_text)
+ embed.set_image(url=STATES[valenstate]["flag"])
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Valenstate Cog."""
+ bot.add_cog(MyValenstate())
diff --git a/bot/exts/holidays/valentines/pickuplines.py b/bot/exts/holidays/valentines/pickuplines.py
new file mode 100644
index 00000000..bc4b88c6
--- /dev/null
+++ b/bot/exts/holidays/valentines/pickuplines.py
@@ -0,0 +1,41 @@
+import logging
+import random
+from json import loads
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+PICKUP_LINES = loads(Path("bot/resources/holidays/valentines/pickup_lines.json").read_text("utf8"))
+
+
+class PickupLine(commands.Cog):
+ """A cog that gives random cheesy pickup lines."""
+
+ @commands.command()
+ async def pickupline(self, ctx: commands.Context) -> None:
+ """
+ Gives you a random pickup line.
+
+ Note that most of them are very cheesy.
+ """
+ random_line = random.choice(PICKUP_LINES["lines"])
+ embed = discord.Embed(
+ title=":cheese: Your pickup line :cheese:",
+ description=random_line["line"],
+ color=Colours.pink
+ )
+ embed.set_thumbnail(
+ url=random_line.get("image", PICKUP_LINES["placeholder"])
+ )
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Pickup lines Cog."""
+ bot.add_cog(PickupLine())
diff --git a/bot/exts/holidays/valentines/savethedate.py b/bot/exts/holidays/valentines/savethedate.py
new file mode 100644
index 00000000..3638c1ef
--- /dev/null
+++ b/bot/exts/holidays/valentines/savethedate.py
@@ -0,0 +1,38 @@
+import logging
+import random
+from json import loads
+from pathlib import Path
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"]
+
+VALENTINES_DATES = loads(Path("bot/resources/holidays/valentines/date_ideas.json").read_text("utf8"))
+
+
+class SaveTheDate(commands.Cog):
+ """A cog that gives random suggestion for a Valentine's date."""
+
+ @commands.command()
+ async def savethedate(self, ctx: commands.Context) -> None:
+ """Gives you ideas for what to do on a date with your valentine."""
+ random_date = random.choice(VALENTINES_DATES["ideas"])
+ emoji_1 = random.choice(HEART_EMOJIS)
+ emoji_2 = random.choice(HEART_EMOJIS)
+ embed = discord.Embed(
+ title=f"{emoji_1}{random_date['name']}{emoji_2}",
+ description=f"{random_date['description']}",
+ colour=Colours.pink
+ )
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Save the date Cog."""
+ bot.add_cog(SaveTheDate())
diff --git a/bot/exts/holidays/valentines/valentine_zodiac.py b/bot/exts/holidays/valentines/valentine_zodiac.py
new file mode 100644
index 00000000..d1b3a630
--- /dev/null
+++ b/bot/exts/holidays/valentines/valentine_zodiac.py
@@ -0,0 +1,146 @@
+import calendar
+import json
+import logging
+import random
+from datetime import datetime
+from pathlib import Path
+from typing import Union
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+LETTER_EMOJI = ":love_letter:"
+HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"]
+
+
+class ValentineZodiac(commands.Cog):
+ """A Cog that returns a counter compatible zodiac sign to the given user's zodiac sign."""
+
+ def __init__(self):
+ self.zodiacs, self.zodiac_fact = self.load_comp_json()
+
+ @staticmethod
+ def load_comp_json() -> tuple[dict, dict]:
+ """Load zodiac compatibility from static JSON resource."""
+ explanation_file = Path("bot/resources/holidays/valentines/zodiac_explanation.json")
+ compatibility_file = Path("bot/resources/holidays/valentines/zodiac_compatibility.json")
+
+ zodiac_fact = json.loads(explanation_file.read_text("utf8"))
+
+ for zodiac_data in zodiac_fact.values():
+ zodiac_data["start_at"] = datetime.fromisoformat(zodiac_data["start_at"])
+ zodiac_data["end_at"] = datetime.fromisoformat(zodiac_data["end_at"])
+
+ zodiacs = json.loads(compatibility_file.read_text("utf8"))
+
+ return zodiacs, zodiac_fact
+
+ def generate_invalidname_embed(self, zodiac: str) -> discord.Embed:
+ """Returns error embed."""
+ embed = discord.Embed()
+ embed.color = Colours.soft_red
+ error_msg = f"**{zodiac}** is not a valid zodiac sign, here is the list of valid zodiac signs.\n"
+ names = list(self.zodiac_fact)
+ middle_index = len(names) // 2
+ first_half_names = ", ".join(names[:middle_index])
+ second_half_names = ", ".join(names[middle_index:])
+ embed.description = error_msg + first_half_names + ",\n" + second_half_names
+ log.info("Invalid zodiac name provided.")
+ return embed
+
+ def zodiac_build_embed(self, zodiac: str) -> discord.Embed:
+ """Gives informative zodiac embed."""
+ zodiac = zodiac.capitalize()
+ embed = discord.Embed()
+ embed.color = Colours.pink
+ if zodiac in self.zodiac_fact:
+ log.trace("Making zodiac embed.")
+ embed.title = f"__{zodiac}__"
+ embed.description = self.zodiac_fact[zodiac]["About"]
+ embed.add_field(name="__Motto__", value=self.zodiac_fact[zodiac]["Motto"], inline=False)
+ embed.add_field(name="__Strengths__", value=self.zodiac_fact[zodiac]["Strengths"], inline=False)
+ embed.add_field(name="__Weaknesses__", value=self.zodiac_fact[zodiac]["Weaknesses"], inline=False)
+ embed.add_field(name="__Full form__", value=self.zodiac_fact[zodiac]["full_form"], inline=False)
+ embed.set_thumbnail(url=self.zodiac_fact[zodiac]["url"])
+ else:
+ embed = self.generate_invalidname_embed(zodiac)
+ log.trace("Successfully created zodiac information embed.")
+ return embed
+
+ def zodiac_date_verifier(self, query_date: datetime) -> str:
+ """Returns zodiac sign by checking date."""
+ for zodiac_name, zodiac_data in self.zodiac_fact.items():
+ if zodiac_data["start_at"].date() <= query_date.date() <= zodiac_data["end_at"].date():
+ log.trace("Zodiac name sent.")
+ return zodiac_name
+
+ @commands.group(name="zodiac", invoke_without_command=True)
+ async def zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:
+ """Provides information about zodiac sign by taking zodiac sign name as input."""
+ final_embed = self.zodiac_build_embed(zodiac_sign)
+ await ctx.send(embed=final_embed)
+ log.trace("Embed successfully sent.")
+
+ @zodiac.command(name="date")
+ async def date_and_month(self, ctx: commands.Context, date: int, month: Union[int, str]) -> None:
+ """Provides information about zodiac sign by taking month and date as input."""
+ if isinstance(month, str):
+ month = month.capitalize()
+ try:
+ month = list(calendar.month_abbr).index(month[:3])
+ log.trace("Valid month name entered by user")
+ except ValueError:
+ log.info("Invalid month name entered by user")
+ await ctx.send(f"Sorry, but `{month}` is not a valid month name.")
+ return
+ if (month == 1 and 1 <= date <= 19) or (month == 12 and 22 <= date <= 31):
+ zodiac = "capricorn"
+ final_embed = self.zodiac_build_embed(zodiac)
+ else:
+ try:
+ zodiac_sign_based_on_date = self.zodiac_date_verifier(datetime(2020, month, date))
+ log.trace("zodiac sign based on month and date received.")
+ except ValueError as e:
+ final_embed = discord.Embed()
+ final_embed.color = Colours.soft_red
+ final_embed.description = f"Zodiac sign could not be found because.\n```\n{e}\n```"
+ log.info(f"Error in 'zodiac date' command:\n{e}.")
+ else:
+ final_embed = self.zodiac_build_embed(zodiac_sign_based_on_date)
+
+ await ctx.send(embed=final_embed)
+ log.trace("Embed from date successfully sent.")
+
+ @zodiac.command(name="partnerzodiac", aliases=("partner",))
+ async def partner_zodiac(self, ctx: commands.Context, zodiac_sign: str) -> None:
+ """Provides a random counter compatible zodiac sign to the given user's zodiac sign."""
+ embed = discord.Embed()
+ embed.color = Colours.pink
+ zodiac_check = self.zodiacs.get(zodiac_sign.capitalize())
+ if zodiac_check:
+ compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.capitalize()])
+ emoji1 = random.choice(HEART_EMOJIS)
+ emoji2 = random.choice(HEART_EMOJIS)
+ embed.title = "Zodiac Compatibility"
+ embed.description = (
+ f"{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac['Zodiac']}\n"
+ f"{emoji2}Compatibility meter : {compatible_zodiac['compatibility_score']}{emoji2}"
+ )
+ embed.add_field(
+ name=f"A letter from Dr.Zodiac {LETTER_EMOJI}",
+ value=compatible_zodiac["description"]
+ )
+ else:
+ embed = self.generate_invalidname_embed(zodiac_sign)
+ await ctx.send(embed=embed)
+ log.trace("Embed from date successfully sent.")
+
+
+def setup(bot: Bot) -> None:
+ """Load the Valentine zodiac Cog."""
+ bot.add_cog(ValentineZodiac())
diff --git a/bot/exts/holidays/valentines/whoisvalentine.py b/bot/exts/holidays/valentines/whoisvalentine.py
new file mode 100644
index 00000000..67e46aa4
--- /dev/null
+++ b/bot/exts/holidays/valentines/whoisvalentine.py
@@ -0,0 +1,49 @@
+import json
+import logging
+from pathlib import Path
+from random import choice
+
+import discord
+from discord.ext import commands
+
+from bot.bot import Bot
+from bot.constants import Colours
+
+log = logging.getLogger(__name__)
+
+FACTS = json.loads(Path("bot/resources/holidays/valentines/valentine_facts.json").read_text("utf8"))
+
+
+class ValentineFacts(commands.Cog):
+ """A Cog for displaying facts about Saint Valentine."""
+
+ @commands.command(aliases=("whoisvalentine", "saint_valentine"))
+ async def who_is_valentine(self, ctx: commands.Context) -> None:
+ """Displays info about Saint Valentine."""
+ embed = discord.Embed(
+ title="Who is Saint Valentine?",
+ description=FACTS["whois"],
+ color=Colours.pink
+ )
+ embed.set_thumbnail(
+ url="https://upload.wikimedia.org/wikipedia/commons/thumb/f/f1/Saint_Valentine_-_"
+ "facial_reconstruction.jpg/1024px-Saint_Valentine_-_facial_reconstruction.jpg"
+ )
+
+ await ctx.send(embed=embed)
+
+ @commands.command()
+ async def valentine_fact(self, ctx: commands.Context) -> None:
+ """Shows a random fact about Valentine's Day."""
+ embed = discord.Embed(
+ title=choice(FACTS["titles"]),
+ description=choice(FACTS["text"]),
+ color=Colours.pink
+ )
+
+ await ctx.send(embed=embed)
+
+
+def setup(bot: Bot) -> None:
+ """Load the Who is Valentine Cog."""
+ bot.add_cog(ValentineFacts())