diff options
| author | 2021-09-05 19:46:37 +0100 | |
|---|---|---|
| committer | 2021-09-05 19:46:37 +0100 | |
| commit | 8a410f3abd39a1b48c514d32651a50d4bdced492 (patch) | |
| tree | 17fbb917adec0a1283d3d2456d8b09eee0334371 /bot/exts/holidays | |
| parent | Merge pull request #845 from python-discord/Pin-platform-in-Dockerfile (diff) | |
| parent | Merge branch 'main' into lance-restructure (diff) | |
Merge pull request #851 from python-discord/lance-restructure
Restructure Sir Lancebot
Diffstat (limited to 'bot/exts/holidays')
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()) |