diff options
| author | 2019-03-27 21:09:23 +0530 | |
|---|---|---|
| committer | 2019-03-27 21:09:23 +0530 | |
| commit | 463e7f9d757ce8142f23f67f55f3c41d4336ba56 (patch) | |
| tree | fc3e6484b77907bc0158c271af26261d013ae508 /bot/seasons | |
| parent | Merge pull request #99 from python-discord/config-update (diff) | |
| parent | Merge pull request #132 from python-discord/dpy-cog-changes (diff) | |
Merge pull request #1 from python-discord/master
syncing fork
Diffstat (limited to 'bot/seasons')
32 files changed, 2896 insertions, 94 deletions
diff --git a/bot/seasons/__init__.py b/bot/seasons/__init__.py index c43334a4..1512fae2 100644 --- a/bot/seasons/__init__.py +++ b/bot/seasons/__init__.py @@ -9,4 +9,4 @@ log = logging.getLogger(__name__) def setup(bot): bot.add_cog(SeasonManager(bot)) - log.debug("SeasonManager cog loaded") + log.info("SeasonManager cog loaded") diff --git a/bot/seasons/christmas/adventofcode.py b/bot/seasons/christmas/adventofcode.py index 3b199a4a..2995c3fd 100644 --- a/bot/seasons/christmas/adventofcode.py +++ b/bot/seasons/christmas/adventofcode.py @@ -108,7 +108,7 @@ async def day_countdown(bot: commands.Bot): await asyncio.sleep(120) -class AdventOfCode: +class AdventOfCode(commands.Cog): """ Advent of Code festivities! Ho Ho Ho! """ @@ -205,14 +205,21 @@ class AdventOfCode: @adventofcode_group.command(name="join", aliases=("j",), brief="Learn how to join PyDis' private AoC leaderboard") async def join_leaderboard(self, ctx: commands.Context): """ - Retrieve the link to join the PyDis AoC private leaderboard + DM the user the information for joining the PyDis AoC private leaderboard """ + author = ctx.message.author + log.info(f"{author.name} ({author.id}) has requested the PyDis AoC leaderboard code") + info_str = ( "Head over to https://adventofcode.com/leaderboard/private " f"with code `{AocConfig.leaderboard_join_code}` to join the PyDis private leaderboard!" ) - await ctx.send(info_str) + try: + await author.send(info_str) + except discord.errors.Forbidden: + log.debug(f"{author.name} ({author.id}) has disabled DMs from server members") + await ctx.send(f":x: {author.mention}, please (temporarily) enable DMs to receive the join code") @adventofcode_group.command( name="leaderboard", @@ -723,4 +730,4 @@ def _error_embed_helper(title: str, description: str) -> discord.Embed: def setup(bot: commands.Bot) -> None: bot.add_cog(AdventOfCode(bot)) - log.info("Cog loaded: adventofcode") + log.info("AdventOfCode cog loaded") diff --git a/bot/seasons/easter/__init__.py b/bot/seasons/easter/__init__.py new file mode 100644 index 00000000..bfad772d --- /dev/null +++ b/bot/seasons/easter/__init__.py @@ -0,0 +1,17 @@ +from bot.seasons import SeasonBase + + +class Easter(SeasonBase): + """ + Easter is a beautiful time of the year often celebrated after the first Full Moon of the new spring season. + This time is quite beautiful due to the colorful flowers coming out to greet us. So. let's greet Spring + in an Easter celebration of contributions. + """ + + name = "easter" + bot_name = "BunnyBot" + greeting = "Happy Easter to us all!" + + # Duration of season + start_date = "01/04" + end_date = "30/04" diff --git a/bot/seasons/evergreen/__init__.py b/bot/seasons/evergreen/__init__.py index db5b5684..db610e7c 100644 --- a/bot/seasons/evergreen/__init__.py +++ b/bot/seasons/evergreen/__init__.py @@ -2,4 +2,4 @@ from bot.seasons import SeasonBase class Evergreen(SeasonBase): - pass + bot_icon = "/logos/logo_seasonal/evergreen/logo_evergreen.png" diff --git a/bot/seasons/evergreen/error_handler.py b/bot/seasons/evergreen/error_handler.py index 6de35e60..7774f06e 100644 --- a/bot/seasons/evergreen/error_handler.py +++ b/bot/seasons/evergreen/error_handler.py @@ -8,102 +8,97 @@ from discord.ext import commands log = logging.getLogger(__name__)
-class CommandErrorHandler:
+class CommandErrorHandler(commands.Cog):
"""A error handler for the PythonDiscord server!"""
def __init__(self, bot):
self.bot = bot
+ @staticmethod
+ def revert_cooldown_counter(command, message):
+ """Undoes the last cooldown counter for user-error cases."""
+ if command._buckets.valid:
+ bucket = command._buckets.get_bucket(message)
+ bucket._tokens = min(bucket.rate, bucket._tokens + 1)
+ logging.debug(
+ "Cooldown counter reverted as the command was not used correctly."
+ )
+
+ @commands.Cog.listener()
async def on_command_error(self, ctx, error):
"""Activates when a command opens an error"""
if hasattr(ctx.command, 'on_error'):
return logging.debug(
- "A command error occured but "
- "the command had it's own error handler"
+ "A command error occured but the command had it's own error handler."
)
+
error = getattr(error, 'original', error)
+
if isinstance(error, commands.CommandNotFound):
return logging.debug(
- f"{ctx.author} called '{ctx.message.content}' "
- "but no command was found"
+ f"{ctx.author} called '{ctx.message.content}' but no command was found."
)
+
if isinstance(error, commands.UserInputError):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "but entered invalid input!"
+ f"{ctx.author} called the command '{ctx.command}' but entered invalid input!"
)
+
+ self.revert_cooldown_counter(ctx.command, ctx.message)
+
return await ctx.send(
- ":no_entry: The command you specified failed to run."
+ ":no_entry: The command you specified failed to run. "
"This is because the arguments you provided were invalid."
)
+
if isinstance(error, commands.CommandOnCooldown):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "but they were on cooldown!"
+ f"{ctx.author} called the command '{ctx.command}' but they were on cooldown!"
)
+ remaining_minutes, remaining_seconds = divmod(error.retry_after, 60)
+
return await ctx.send(
- "This command is on cooldown,"
- f" please retry in {math.ceil(error.retry_after)}s."
+ "This command is on cooldown, please retry in "
+ f"{int(remaining_minutes)} minutes {math.ceil(remaining_seconds)} seconds."
)
+
if isinstance(error, commands.DisabledCommand):
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "but the command was disabled!"
- )
- return await ctx.send(
- ":no_entry: This command has been disabled."
+ f"{ctx.author} called the command '{ctx.command}' but the command was disabled!"
)
+ return await ctx.send(":no_entry: This command has been disabled.")
+
if isinstance(error, commands.NoPrivateMessage):
logging.debug(
f"{ctx.author} called the command '{ctx.command}' "
"in a private message however the command was guild only!"
)
- return await ctx.author.send(
- ":no_entry: This command can only be used inside a server."
- )
+ return await ctx.author.send(":no_entry: This command can only be used in the server.")
+
if isinstance(error, commands.BadArgument):
- if ctx.command.qualified_name == 'tag list':
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "but entered an invalid user!"
- )
- return await ctx.send(
- "I could not find that member. Please try again."
- )
- else:
- logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "but entered a bad argument!"
- )
- return await ctx.send(
- "The argument you provided was invalid."
- )
- if isinstance(error, commands.CheckFailure):
+ self.revert_cooldown_counter(ctx.command, ctx.message)
+
logging.debug(
- f"{ctx.author} called the command '{ctx.command}' "
- "but the checks failed!"
- )
- return await ctx.send(
- ":no_entry: You are not authorized to use this command."
+ f"{ctx.author} called the command '{ctx.command}' but entered a bad argument!"
)
- print(
- f"Ignoring exception in command {ctx.command}:",
- file=sys.stderr
- )
+ return await ctx.send("The argument you provided was invalid.")
+
+ if isinstance(error, commands.CheckFailure):
+ logging.debug(f"{ctx.author} called the command '{ctx.command}' but the checks failed!")
+ return await ctx.send(":no_entry: You are not authorized to use this command.")
+
+ print(f"Ignoring exception in command {ctx.command}:", file=sys.stderr)
+
logging.warning(
f"{ctx.author} called the command '{ctx.command}' "
"however the command failed to run with the error:"
f"-------------\n{error}"
)
- traceback.print_exception(
- type(error),
- error,
- error.__traceback__,
- file=sys.stderr
- )
+
+ traceback.print_exception(type(error), error, error.__traceback__, file=sys.stderr)
def setup(bot):
bot.add_cog(CommandErrorHandler(bot))
- log.debug("CommandErrorHandler cog loaded")
+ log.info("CommandErrorHandler cog loaded")
diff --git a/bot/seasons/evergreen/fun.py b/bot/seasons/evergreen/fun.py new file mode 100644 index 00000000..9ef47331 --- /dev/null +++ b/bot/seasons/evergreen/fun.py @@ -0,0 +1,38 @@ +import logging +import random + +from discord.ext import commands + +from bot.constants import Emojis + +log = logging.getLogger(__name__) + + +class Fun(commands.Cog): + """ + A collection of general commands for fun. + """ + + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def roll(self, ctx, num_rolls: int = 1): + """ + Outputs a number of random dice emotes (up to 6) + """ + output = "" + if num_rolls > 6: + num_rolls = 6 + elif num_rolls < 1: + output = ":no_entry: You must roll at least once." + for _ in range(num_rolls): + terning = f"terning{random.randint(1, 6)}" + output += getattr(Emojis, terning, '') + await ctx.send(output) + + +# Required in order to load the cog, use the class name in the add_cog function. +def setup(bot): + bot.add_cog(Fun(bot)) + log.info("Fun cog loaded") diff --git a/bot/seasons/evergreen/magic_8ball.py b/bot/seasons/evergreen/magic_8ball.py new file mode 100644 index 00000000..88c9fd26 --- /dev/null +++ b/bot/seasons/evergreen/magic_8ball.py @@ -0,0 +1,36 @@ +import json +import logging +import random +from pathlib import Path + +from discord.ext import commands + +log = logging.getLogger(__name__) + + +class Magic8ball: + """ + A Magic 8ball command to respond to a users question. + """ + + def __init__(self, bot): + self.bot = bot + with open(Path("bot", "resources", "evergreen", "magic8ball.json"), "r") as file: + self.answers = json.load(file) + + @commands.command(name="8ball") + async def output_answer(self, ctx, *, question): + """ + Return a magic 8 ball answer from answers list. + """ + if len(question.split()) >= 3: + answer = random.choice(self.answers) + await ctx.send(answer) + else: + await ctx.send("Usage: .8ball <question> (minimum length of 3 eg: `will I win?`)") + + +# Required in order to load the cog, use the class name in the add_cog function. +def setup(bot): + bot.add_cog(Magic8ball(bot)) + log.info("Magic 8ball cog loaded") diff --git a/bot/seasons/evergreen/snakes/__init__.py b/bot/seasons/evergreen/snakes/__init__.py new file mode 100644 index 00000000..6fb1f673 --- /dev/null +++ b/bot/seasons/evergreen/snakes/__init__.py @@ -0,0 +1,10 @@ +import logging + +from bot.seasons.evergreen.snakes.snakes_cog import Snakes + +log = logging.getLogger(__name__) + + +def setup(bot): + bot.add_cog(Snakes(bot)) + log.info("Snakes cog loaded") diff --git a/bot/seasons/evergreen/snakes/converter.py b/bot/seasons/evergreen/snakes/converter.py new file mode 100644 index 00000000..c091d9c1 --- /dev/null +++ b/bot/seasons/evergreen/snakes/converter.py @@ -0,0 +1,80 @@ +import json +import logging +import random + +import discord +from discord.ext.commands import Converter +from fuzzywuzzy import fuzz + +from bot.seasons.evergreen.snakes.utils import SNAKE_RESOURCES +from bot.utils import disambiguate + +log = logging.getLogger(__name__) + + +class Snake(Converter): + snakes = None + special_cases = None + + async def convert(self, ctx, name): + await self.build_list() + name = name.lower() + + if name == 'python': + return 'Python (programming language)' + + def get_potential(iterable, *, threshold=80): + nonlocal name + potential = [] + + for item in iterable: + original, item = item, item.lower() + + if name == item: + return [original] + + a, b = fuzz.ratio(name, item), fuzz.partial_ratio(name, item) + if a >= threshold or b >= threshold: + potential.append(original) + + return potential + + # Handle special cases + if name.lower() in self.special_cases: + return self.special_cases.get(name.lower(), name.lower()) + + names = {snake['name']: snake['scientific'] for snake in self.snakes} + all_names = names.keys() | names.values() + timeout = len(all_names) * (3 / 4) + + embed = discord.Embed( + title='Found multiple choices. Please choose the correct one.', colour=0x59982F) + embed.set_author(name=ctx.author.display_name, icon_url=ctx.author.avatar_url) + + name = await disambiguate(ctx, get_potential(all_names), timeout=timeout, embed=embed) + return names.get(name, name) + + @classmethod + async def build_list(cls): + # Get all the snakes + if cls.snakes is None: + with (SNAKE_RESOURCES / "snake_names.json").open() as snakefile: + cls.snakes = json.load(snakefile) + + # Get the special cases + if cls.special_cases is None: + with (SNAKE_RESOURCES / "special_snakes.json").open() as snakefile: + special_cases = json.load(snakefile) + cls.special_cases = {snake['name'].lower(): snake for snake in special_cases} + + @classmethod + async def random(cls): + """ + This is stupid. We should find a way to + somehow get the global session into a + global context, so I can get it from here. + :return: + """ + await cls.build_list() + names = [snake['scientific'] for snake in cls.snakes] + return random.choice(names) diff --git a/bot/seasons/evergreen/snakes/snakes_cog.py b/bot/seasons/evergreen/snakes/snakes_cog.py new file mode 100644 index 00000000..74d2ab4f --- /dev/null +++ b/bot/seasons/evergreen/snakes/snakes_cog.py @@ -0,0 +1,1189 @@ +import asyncio +import colorsys +import logging +import os +import random +import re +import string +import textwrap +import urllib +from functools import partial +from io import BytesIO +from typing import Any, Dict + +import aiohttp +import async_timeout +from PIL import Image, ImageDraw, ImageFont +from discord import Colour, Embed, File, Member, Message, Reaction +from discord.ext.commands import BadArgument, Bot, Cog, Context, bot_has_permissions, group + +from bot.constants import ERROR_REPLIES, Tokens +from bot.decorators import locked +from bot.seasons.evergreen.snakes import utils +from bot.seasons.evergreen.snakes.converter import Snake + +log = logging.getLogger(__name__) + + +# region: Constants +# Color +SNAKE_COLOR = 0x399600 + +# Antidote constants +SYRINGE_EMOJI = "\U0001F489" # :syringe: +PILL_EMOJI = "\U0001F48A" # :pill: +HOURGLASS_EMOJI = "\u231B" # :hourglass: +CROSSBONES_EMOJI = "\u2620" # :skull_crossbones: +ALEMBIC_EMOJI = "\u2697" # :alembic: +TICK_EMOJI = "\u2705" # :white_check_mark: - Correct peg, correct hole +CROSS_EMOJI = "\u274C" # :x: - Wrong peg, wrong hole +BLANK_EMOJI = "\u26AA" # :white_circle: - Correct peg, wrong hole +HOLE_EMOJI = "\u2B1C" # :white_square: - Used in guesses +EMPTY_UNICODE = "\u200b" # literally just an empty space + +ANTIDOTE_EMOJI = ( + SYRINGE_EMOJI, + PILL_EMOJI, + HOURGLASS_EMOJI, + CROSSBONES_EMOJI, + ALEMBIC_EMOJI, +) + +# Quiz constants +ANSWERS_EMOJI = { + "a": "\U0001F1E6", # :regional_indicator_a: 🇦 + "b": "\U0001F1E7", # :regional_indicator_b: 🇧 + "c": "\U0001F1E8", # :regional_indicator_c: 🇨 + "d": "\U0001F1E9", # :regional_indicator_d: 🇩 +} + +ANSWERS_EMOJI_REVERSE = { + "\U0001F1E6": "A", # :regional_indicator_a: 🇦 + "\U0001F1E7": "B", # :regional_indicator_b: 🇧 + "\U0001F1E8": "C", # :regional_indicator_c: 🇨 + "\U0001F1E9": "D", # :regional_indicator_d: 🇩 +} + +# Zzzen of pythhhon constant +ZEN = """ +Beautiful is better than ugly. +Explicit is better than implicit. +Simple is better than complex. +Complex is better than complicated. +Flat is better than nested. +Sparse is better than dense. +Readability counts. +Special cases aren't special enough to break the rules. +Although practicality beats purity. +Errors should never pass silently. +Unless explicitly silenced. +In the face of ambiguity, refuse the temptation to guess. +There should be one-- and preferably only one --obvious way to do it. +Now is better than never. +Although never is often better than *right* now. +If the implementation is hard to explain, it's a bad idea. +If the implementation is easy to explain, it may be a good idea. +""" + +# Max messages to train snake_chat on +MSG_MAX = 100 + +# get_snek constants +URL = "https://en.wikipedia.org/w/api.php?" + +# snake guess responses +INCORRECT_GUESS = ( + "Nope, that's not what it is.", + "Not quite.", + "Not even close.", + "Terrible guess.", + "Nnnno.", + "Dude. No.", + "I thought everyone knew this one.", + "Guess you suck at snakes.", + "Bet you feel stupid now.", + "Hahahaha, no.", + "Did you hit the wrong key?" +) + +CORRECT_GUESS = ( + "**WRONG**. Wait, no, actually you're right.", + "Yeah, you got it!", + "Yep, that's exactly what it is.", + "Uh-huh. Yep yep yep.", + "Yeah that's right.", + "Yup. How did you know that?", + "Are you a herpetologist?", + "Sure, okay, but I bet you can't pronounce it.", + "Are you cheating?" +) + +# snake card consts +CARD = { + "top": Image.open("bot/resources/snakes/snake_cards/card_top.png"), + "frame": Image.open("bot/resources/snakes/snake_cards/card_frame.png"), + "bottom": Image.open("bot/resources/snakes/snake_cards/card_bottom.png"), + "backs": [ + Image.open(f"bot/resources/snakes/snake_cards/backs/{file}") + for file in os.listdir("bot/resources/snakes/snake_cards/backs") + ], + "font": ImageFont.truetype("bot/resources/snakes/snake_cards/expressway.ttf", 20) +} +# endregion + + +class Snakes(Cog): + """ + Commands related to snakes. These were created by our + community during the first code jam. + + More information can be found in the code-jam-1 repo. + + https://gitlab_bot_repo.com/discord-python/code-jams/code-jam-1 + """ + + wiki_brief = re.compile(r'(.*?)(=+ (.*?) =+)', flags=re.DOTALL) + valid_image_extensions = ('gif', 'png', 'jpeg', 'jpg', 'webp') + + def __init__(self, bot: Bot): + self.active_sal = {} + self.bot = bot + self.snake_names = utils.get_resource("snake_names") + self.snake_idioms = utils.get_resource("snake_idioms") + self.snake_quizzes = utils.get_resource("snake_quiz") + self.snake_facts = utils.get_resource("snake_facts") + + # region: Helper methods + @staticmethod + def _beautiful_pastel(hue): + """ + Returns random bright pastels. + """ + light = random.uniform(0.7, 0.85) + saturation = 1 + + rgb = colorsys.hls_to_rgb(hue, light, saturation) + hex_rgb = "" + + for part in rgb: + value = int(part * 0xFF) + hex_rgb += f"{value:02x}" + + return int(hex_rgb, 16) + + @staticmethod + def _generate_card(buffer: BytesIO, content: dict) -> BytesIO: + """ + Generate a card from snake information. + + Written by juan and Someone during the first code jam. + """ + snake = Image.open(buffer) + + # Get the size of the snake icon, configure the height of the image box (yes, it changes) + icon_width = 347 # Hardcoded, not much i can do about that + icon_height = int((icon_width / snake.width) * snake.height) + frame_copies = icon_height // CARD['frame'].height + 1 + snake.thumbnail((icon_width, icon_height)) + + # Get the dimensions of the final image + main_height = icon_height + CARD['top'].height + CARD['bottom'].height + main_width = CARD['frame'].width + + # Start creating the foreground + foreground = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + foreground.paste(CARD['top'], (0, 0)) + + # Generate the frame borders to the correct height + for offset in range(frame_copies): + position = (0, CARD['top'].height + offset * CARD['frame'].height) + foreground.paste(CARD['frame'], position) + + # Add the image and bottom part of the image + foreground.paste(snake, (36, CARD['top'].height)) # Also hardcoded :( + foreground.paste(CARD['bottom'], (0, CARD['top'].height + icon_height)) + + # Setup the background + back = random.choice(CARD['backs']) + back_copies = main_height // back.height + 1 + full_image = Image.new("RGBA", (main_width, main_height), (0, 0, 0, 0)) + + # Generate the tiled background + for offset in range(back_copies): + full_image.paste(back, (16, 16 + offset * back.height)) + + # Place the foreground onto the final image + full_image.paste(foreground, (0, 0), foreground) + + # Get the first two sentences of the info + description = '.'.join(content['info'].split(".")[:2]) + '.' + + # Setup positioning variables + margin = 36 + offset = CARD['top'].height + icon_height + margin + + # Create blank rectangle image which will be behind the text + rectangle = Image.new( + "RGBA", + (main_width, main_height), + (0, 0, 0, 0) + ) + + # Draw a semi-transparent rectangle on it + rect = ImageDraw.Draw(rectangle) + rect.rectangle( + (margin, offset, main_width - margin, main_height - margin), + fill=(63, 63, 63, 128) + ) + + # Paste it onto the final image + full_image.paste(rectangle, (0, 0), mask=rectangle) + + # Draw the text onto the final image + draw = ImageDraw.Draw(full_image) + for line in textwrap.wrap(description, 36): + draw.text([margin + 4, offset], line, font=CARD['font']) + offset += CARD['font'].getsize(line)[1] + + # Get the image contents as a BufferIO object + buffer = BytesIO() + full_image.save(buffer, 'PNG') + buffer.seek(0) + + return buffer + + @staticmethod + def _snakify(message): + """ + Sssnakifffiesss a sstring. + """ + # Replace fricatives with exaggerated snake fricatives. + simple_fricatives = [ + "f", "s", "z", "h", + "F", "S", "Z", "H", + ] + complex_fricatives = [ + "th", "sh", "Th", "Sh" + ] + + for letter in simple_fricatives: + if letter.islower(): + message = message.replace(letter, letter * random.randint(2, 4)) + else: + message = message.replace(letter, (letter * random.randint(2, 4)).title()) + + for fricative in complex_fricatives: + message = message.replace(fricative, fricative[0] + fricative[1] * random.randint(2, 4)) + + return message + + async def _fetch(self, session, url, params=None): + """ + Asyncronous web request helper method. + """ + if params is None: + params = {} + + async with async_timeout.timeout(10): + async with session.get(url, params=params) as response: + return await response.json() + + def _get_random_long_message(self, messages, retries=10): + """ + Fetch a message that's at least 3 words long, + but only if it is possible to do so in retries + attempts. Else, just return whatever the last + message is. + """ + long_message = random.choice(messages) + if len(long_message.split()) < 3 and retries > 0: + return self._get_random_long_message( + messages, + retries=retries - 1 + ) + + return long_message + + async def _get_snek(self, name: str) -> Dict[str, Any]: + """ + Goes online and fetches all the data from a wikipedia article + about a snake. Builds a dict that the .get() method can use. + + Created by Ava and eivl. + + :param name: The name of the snake to get information for - omit for a random snake + :return: A dict containing information on a snake + """ + snake_info = {} + + async with aiohttp.ClientSession() as session: + params = { + 'format': 'json', + 'action': 'query', + 'list': 'search', + 'srsearch': name, + 'utf8': '', + 'srlimit': '1', + } + + json = await self._fetch(session, URL, params=params) + + # wikipedia does have a error page + try: + pageid = json["query"]["search"][0]["pageid"] + except KeyError: + # Wikipedia error page ID(?) + pageid = 41118 + except IndexError: + return None + + params = { + 'format': 'json', + 'action': 'query', + 'prop': 'extracts|images|info', + 'exlimit': 'max', + 'explaintext': '', + 'inprop': 'url', + 'pageids': pageid + } + + json = await self._fetch(session, URL, params=params) + + # constructing dict - handle exceptions later + try: + snake_info["title"] = json["query"]["pages"][f"{pageid}"]["title"] + snake_info["extract"] = json["query"]["pages"][f"{pageid}"]["extract"] + snake_info["images"] = json["query"]["pages"][f"{pageid}"]["images"] + snake_info["fullurl"] = json["query"]["pages"][f"{pageid}"]["fullurl"] + snake_info["pageid"] = json["query"]["pages"][f"{pageid}"]["pageid"] + except KeyError: + snake_info["error"] = True + + if snake_info["images"]: + i_url = 'https://commons.wikimedia.org/wiki/Special:FilePath/' + image_list = [] + map_list = [] + thumb_list = [] + + # Wikipedia has arbitrary images that are not snakes + banned = [ + 'Commons-logo.svg', + 'Red%20Pencil%20Icon.png', + 'distribution', + 'The%20Death%20of%20Cleopatra%20arthur.jpg', + 'Head%20of%20holotype', + 'locator', + 'Woma.png', + '-map.', + '.svg', + 'ange.', + 'Adder%20(PSF).png' + ] + + for image in snake_info["images"]: + # images come in the format of `File:filename.extension` + file, sep, filename = image["title"].partition(':') + filename = filename.replace(" ", "%20") # Wikipedia returns good data! + + if not filename.startswith('Map'): + if any(ban in filename for ban in banned): + pass + else: + image_list.append(f"{i_url}{filename}") + thumb_list.append(f"{i_url}{filename}?width=100") + else: + map_list.append(f"{i_url}{filename}") + + snake_info["image_list"] = image_list + snake_info["map_list"] = map_list + snake_info["thumb_list"] = thumb_list + snake_info["name"] = name + + match = self.wiki_brief.match(snake_info['extract']) + info = match.group(1) if match else None + + if info: + info = info.replace("\n", "\n\n") # Give us some proper paragraphs. + + snake_info["info"] = info + + return snake_info + + async def _get_snake_name(self) -> Dict[str, str]: + """ + Gets a random snake name. + :return: A random snake name, as a string. + """ + return random.choice(self.snake_names) + + async def _validate_answer(self, ctx: Context, message: Message, answer: str, options: list): + """ + Validate the answer using a reaction event loop + :return: + """ + + def predicate(reaction, user): + """ + Test if the the answer is valid and can be evaluated. + """ + return ( + reaction.message.id == message.id # The reaction is attached to the question we asked. + and user == ctx.author # It's the user who triggered the quiz. + and str(reaction.emoji) in ANSWERS_EMOJI.values() # The reaction is one of the options. + ) + + for emoji in ANSWERS_EMOJI.values(): + await message.add_reaction(emoji) + + # Validate the answer + try: + reaction, user = await ctx.bot.wait_for("reaction_add", timeout=45.0, check=predicate) + except asyncio.TimeoutError: + await ctx.channel.send(f"You took too long. The correct answer was **{options[answer]}**.") + await message.clear_reactions() + return + + if str(reaction.emoji) == ANSWERS_EMOJI[answer]: + await ctx.send(f"{random.choice(CORRECT_GUESS)} The correct answer was **{options[answer]}**.") + else: + await ctx.send( + f"{random.choice(INCORRECT_GUESS)} The correct answer was **{options[answer]}**." + ) + + await message.clear_reactions() + # endregion + + # region: Commands + @group(name='snakes', aliases=('snake',), invoke_without_command=True) + async def snakes_group(self, ctx: Context): + """Commands from our first code jam.""" + + await ctx.invoke(self.bot.get_command("help"), "snake") + + @bot_has_permissions(manage_messages=True) + @snakes_group.command(name='antidote') + @locked() + async def antidote_command(self, ctx: Context): + """ + Antidote - Can you create the antivenom before the patient dies? + + Rules: You have 4 ingredients for each antidote, you only have 10 attempts + Once you synthesize the antidote, you will be presented with 4 markers + Tick: This means you have a CORRECT ingredient in the CORRECT position + Circle: This means you have a CORRECT ingredient in the WRONG position + Cross: This means you have a WRONG ingredient in the WRONG position + + Info: The game automatically ends after 5 minutes inactivity. + You should only use each ingredient once. + + This game was created by Lord Bisk and Runew0lf. + """ + + def predicate(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + + return ( + all(( + # Reaction is on this message + reaction_.message.id == board_id.id, + # Reaction is one of the pagination emotes + reaction_.emoji in ANTIDOTE_EMOJI, + # Reaction was not made by the Bot + user_.id != self.bot.user.id, + # Reaction was made by author + user_.id == ctx.author.id + )) + ) + + # Initialize variables + antidote_tries = 0 + antidote_guess_count = 0 + antidote_guess_list = [] + guess_result = [] + board = [] + page_guess_list = [] + page_result_list = [] + win = False + + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + + # Generate answer + antidote_answer = list(ANTIDOTE_EMOJI) # Duplicate list, not reference it + random.shuffle(antidote_answer) + antidote_answer.pop() + + # Begin initial board building + for i in range(0, 10): + page_guess_list.append(f"{HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI} {HOLE_EMOJI}") + page_result_list.append(f"{CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI} {CROSS_EMOJI}") + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + antidote_embed.add_field(name="10 guesses remaining", value="\n".join(board)) + board_id = await ctx.send(embed=antidote_embed) # Display board + + # Add our player reactions + for emoji in ANTIDOTE_EMOJI: + await board_id.add_reaction(emoji) + + # Begin main game loop + while not win and antidote_tries < 10: + try: + reaction, user = await ctx.bot.wait_for( + "reaction_add", timeout=300, check=predicate) + except asyncio.TimeoutError: + log.debug("Antidote timed out waiting for a reaction") + break # We're done, no reactions for the last 5 minutes + + if antidote_tries < 10: + if antidote_guess_count < 4: + if reaction.emoji in ANTIDOTE_EMOJI: + antidote_guess_list.append(reaction.emoji) + antidote_guess_count += 1 + + if antidote_guess_count == 4: # Guesses complete + antidote_guess_count = 0 + page_guess_list[antidote_tries] = " ".join(antidote_guess_list) + + # Now check guess + for i in range(0, len(antidote_answer)): + if antidote_guess_list[i] == antidote_answer[i]: + guess_result.append(TICK_EMOJI) + elif antidote_guess_list[i] in antidote_answer: + guess_result.append(BLANK_EMOJI) + else: + guess_result.append(CROSS_EMOJI) + guess_result.sort() + page_result_list[antidote_tries] = " ".join(guess_result) + + # Rebuild the board + board = [] + for i in range(0, 10): + board.append(f"`{i+1:02d}` " + f"{page_guess_list[i]} - " + f"{page_result_list[i]}") + board.append(EMPTY_UNICODE) + + # Remove Reactions + for emoji in antidote_guess_list: + await board_id.remove_reaction(emoji, user) + + if antidote_guess_list == antidote_answer: + win = True + + antidote_tries += 1 + guess_result = [] + antidote_guess_list = [] + + antidote_embed.clear_fields() + antidote_embed.add_field(name=f"{10 - antidote_tries} " + f"guesses remaining", + value="\n".join(board)) + # Redisplay the board + await board_id.edit(embed=antidote_embed) + + # Winning / Ending Screen + if win is True: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://i.makeagif.com/media/7-12-2015/Cj1pts.gif") + antidote_embed.add_field(name=f"You have created the snake antidote!", + value=f"The solution was: {' '.join(antidote_answer)}\n" + f"You had {10 - antidote_tries} tries remaining.") + await board_id.edit(embed=antidote_embed) + else: + antidote_embed = Embed(color=SNAKE_COLOR, title="Antidote") + antidote_embed.set_author(name=ctx.author.name, icon_url=ctx.author.avatar_url) + antidote_embed.set_image(url="https://media.giphy.com/media/ceeN6U57leAhi/giphy.gif") + antidote_embed.add_field(name=EMPTY_UNICODE, + value=f"Sorry you didnt make the antidote in time.\n" + f"The formula was {' '.join(antidote_answer)}") + await board_id.edit(embed=antidote_embed) + + log.debug("Ending pagination and removing all reactions...") + await board_id.clear_reactions() + + @snakes_group.command(name='draw') + async def draw_command(self, ctx: Context): + """ + Draws a random snek using Perlin noise + + Written by Momo and kel. + Modified by juan and lemon. + """ + + with ctx.typing(): + + # Generate random snake attributes + width = random.randint(6, 10) + length = random.randint(15, 22) + random_hue = random.random() + snek_color = self._beautiful_pastel(random_hue) + text_color = self._beautiful_pastel((random_hue + 0.5) % 1) + bg_color = ( + random.randint(32, 50), + random.randint(32, 50), + random.randint(50, 70), + ) + + # Build and send the snek + text = random.choice(self.snake_idioms)["idiom"] + factory = utils.PerlinNoiseFactory(dimension=1, octaves=2) + image_frame = utils.create_snek_frame( + factory, + snake_width=width, + snake_length=length, + snake_color=snek_color, + text=text, + text_color=text_color, + bg_color=bg_color + ) + png_bytes = utils.frame_to_png_bytes(image_frame) + file = File(png_bytes, filename='snek.png') + await ctx.send(file=file) + + @snakes_group.command(name='get') + @bot_has_permissions(manage_messages=True) + @locked() + async def get_command(self, ctx: Context, *, name: Snake = None): + """ + Fetches information about a snake from Wikipedia. + :param ctx: Context object passed from discord.py + :param name: Optional, the name of the snake to get information + for - omit for a random snake + + Created by Ava and eivl. + """ + with ctx.typing(): + if name is None: + name = await Snake.random() + + if isinstance(name, dict): + data = name + else: + data = await self._get_snek(name) + + if data.get('error'): + return await ctx.send('Could not fetch data from Wikipedia.') + + description = data["info"] + + # Shorten the description if needed + if len(description) > 1000: + description = description[:1000] + last_newline = description.rfind("\n") + if last_newline > 0: + description = description[:last_newline] + + # Strip and add the Wiki link. + if "fullurl" in data: + description = description.strip("\n") + description += f"\n\nRead more on [Wikipedia]({data['fullurl']})" + + # Build and send the embed. + embed = Embed( + title=data.get("title", data.get('name')), + description=description, + colour=0x59982F, + ) + + emoji = 'https://emojipedia-us.s3.amazonaws.com/thumbs/60/google/3/snake_1f40d.png' + image = next((url for url in data['image_list'] + if url.endswith(self.valid_image_extensions)), emoji) + embed.set_image(url=image) + + await ctx.send(embed=embed) + + @snakes_group.command(name='guess', aliases=('identify',)) + @locked() + async def guess_command(self, ctx): + """ + Snake identifying game! + + Made by Ava and eivl. + Modified by lemon. + """ + with ctx.typing(): + + image = None + + while image is None: + snakes = [await Snake.random() for _ in range(4)] + snake = random.choice(snakes) + answer = "abcd"[snakes.index(snake)] + + data = await self._get_snek(snake) + + image = next((url for url in data['image_list'] + if url.endswith(self.valid_image_extensions)), None) + + embed = Embed( + title='Which of the following is the snake in the image?', + description="\n".join( + f"{'ABCD'[snakes.index(snake)]}: {snake}" for snake in snakes), + colour=SNAKE_COLOR + ) + embed.set_image(url=image) + + guess = await ctx.send(embed=embed) + options = {f"{'abcd'[snakes.index(snake)]}": snake for snake in snakes} + await self._validate_answer(ctx, guess, answer, options) + + @snakes_group.command(name='hatch') + async def hatch_command(self, ctx: Context): + """ + Hatches your personal snake + + Written by Momo and kel. + """ + # Pick a random snake to hatch. + snake_name = random.choice(list(utils.snakes.keys())) + snake_image = utils.snakes[snake_name] + + # Hatch the snake + message = await ctx.channel.send(embed=Embed(description="Hatching your snake :snake:...")) + await asyncio.sleep(1) + + for stage in utils.stages: + hatch_embed = Embed(description=stage) + await message.edit(embed=hatch_embed) + await asyncio.sleep(1) + await asyncio.sleep(1) + await message.delete() + + # Build and send the embed. + my_snake_embed = Embed(description=":tada: Congrats! You hatched: **{0}**".format(snake_name)) + my_snake_embed.set_thumbnail(url=snake_image) + my_snake_embed.set_footer( + text=" Owner: {0}#{1}".format(ctx.message.author.name, ctx.message.author.discriminator) + ) + + await ctx.channel.send(embed=my_snake_embed) + + @snakes_group.command(name='movie') + async def movie_command(self, ctx: Context): + """ + Gets a random snake-related movie from OMDB. + + Written by Samuel. + Modified by gdude. + """ + url = "http://www.omdbapi.com/" + page = random.randint(1, 27) + + response = await self.bot.http_session.get( + url, + params={ + "s": "snake", + "page": page, + "type": "movie", + "apikey": Tokens.omdb + } + ) + data = await response.json() + movie = random.choice(data["Search"])["imdbID"] + + response = await self.bot.http_session.get( + url, + params={ + "i": movie, + "apikey": Tokens.omdb + } + ) + data = await response.json() + + embed = Embed( + title=data["Title"], + color=SNAKE_COLOR + ) + + del data["Response"], data["imdbID"], data["Title"] + + for key, value in data.items(): + if not value or value == "N/A" or key in ("Response", "imdbID", "Title", "Type"): + continue + + if key == "Ratings": # [{'Source': 'Internet Movie Database', 'Value': '7.6/10'}] + rating = random.choice(value) + + if rating["Source"] != "Internet Movie Database": + embed.add_field(name=f"Rating: {rating['Source']}", value=rating["Value"]) + + continue + + if key == "Poster": + embed.set_image(url=value) + continue + + elif key == "imdbRating": + key = "IMDB Rating" + + elif key == "imdbVotes": + key = "IMDB Votes" + + embed.add_field(name=key, value=value, inline=True) + + embed.set_footer(text="Data provided by the OMDB API") + + await ctx.channel.send( + embed=embed + ) + + @snakes_group.command(name='quiz') + @locked() + async def quiz_command(self, ctx: Context): + """ + Asks a snake-related question in the chat and validates the user's guess. + + This was created by Mushy and Cardium, + and modified by Urthas and lemon. + """ + # Prepare a question. + question = random.choice(self.snake_quizzes) + answer = question["answerkey"] + options = {key: question["options"][key] for key in ANSWERS_EMOJI.keys()} + + # Build and send the embed. + embed = Embed( + color=SNAKE_COLOR, + title=question["question"], + description="\n".join( + [f"**{key.upper()}**: {answer}" for key, answer in options.items()] + ) + ) + + quiz = await ctx.channel.send("", embed=embed) + await self._validate_answer(ctx, quiz, answer, options) + + @snakes_group.command(name='name', aliases=('name_gen',)) + async def name_command(self, ctx: Context, *, name: str = None): + """ + Slices the users name at the last vowel (or second last if the name + ends with a vowel), and then combines it with a random snake name, + which is sliced at the first vowel (or second if the name starts with + a vowel). + + If the name contains no vowels, it just appends the snakename + to the end of the name. + + Examples: + lemon + anaconda = lemoconda + krzsn + anaconda = krzsnconda + gdude + anaconda = gduconda + aperture + anaconda = apertuconda + lucy + python = luthon + joseph + taipan = joseipan + + This was written by Iceman, and modified for inclusion into the bot by lemon. + """ + snake_name = await self._get_snake_name() + snake_name = snake_name['name'] + snake_prefix = "" + + # Set aside every word in the snake name except the last. + if " " in snake_name: + snake_prefix = " ".join(snake_name.split()[:-1]) + snake_name = snake_name.split()[-1] + + # If no name is provided, use whoever called the command. + if name: + user_name = name + else: + user_name = ctx.author.display_name + + # Get the index of the vowel to slice the username at + user_slice_index = len(user_name) + for index, char in enumerate(reversed(user_name)): + if index == 0: + continue + if char.lower() in "aeiouy": + user_slice_index -= index + break + + # Now, get the index of the vowel to slice the snake_name at + snake_slice_index = 0 + for index, char in enumerate(snake_name): + if index == 0: + continue + if char.lower() in "aeiouy": + snake_slice_index = index + 1 + break + + # Combine! + snake_name = snake_name[snake_slice_index:] + user_name = user_name[:user_slice_index] + result = f"{snake_prefix} {user_name}{snake_name}" + result = string.capwords(result) + + # Embed and send + embed = Embed( + title="Snake name", + description=f"Your snake-name is **{result}**", + color=SNAKE_COLOR + ) + + return await ctx.send(embed=embed) + + @snakes_group.command(name='sal') + @locked() + async def sal_command(self, ctx: Context): + """ + Play a game of Snakes and Ladders! + + Written by Momo and kel. + Modified by lemon. + """ + # check if there is already a game in this channel + if ctx.channel in self.active_sal: + await ctx.send(f"{ctx.author.mention} A game is already in progress in this channel.") + return + + game = utils.SnakeAndLaddersGame(snakes=self, context=ctx) + self.active_sal[ctx.channel] = game + + await game.open_game() + + @snakes_group.command(name='about') + async def about_command(self, ctx: Context): + """ + A command that shows an embed with information about the event, + it's participants, and its winners. + """ + contributors = [ + "<@!245270749919576066>", + "<@!396290259907903491>", + "<@!172395097705414656>", + "<@!361708843425726474>", + "<@!300302216663793665>", + "<@!210248051430916096>", + "<@!174588005745557505>", + "<@!87793066227822592>", + "<@!211619754039967744>", + "<@!97347867923976192>", + "<@!136081839474343936>", + "<@!263560579770220554>", + "<@!104749643715387392>", + "<@!303940835005825024>", + ] + + embed = Embed( + title="About the snake cog", + description=( + "The features in this cog were created by members of the community " + "during our first ever [code jam event](https://gitlab.com/discord-python/code-jams/code-jam-1). \n\n" + "The event saw over 50 participants, who competed to write a discord bot cog with a snake theme over " + "48 hours. The staff then selected the best features from all the best teams, and made modifications " + "to ensure they would all work together before integrating them into the community bot.\n\n" + "It was a tight race, but in the end, <@!104749643715387392> and <@!303940835005825024> " + "walked away as grand champions. Make sure you check out `!snakes sal`, `!snakes draw` " + "and `!snakes hatch` to see what they came up with." + ) + ) + + embed.add_field( + name="Contributors", + value=( + ", ".join(contributors) + ) + ) + + await ctx.channel.send(embed=embed) + + @snakes_group.command(name='card') + async def card_command(self, ctx: Context, *, name: Snake = None): + """ + Create an interesting little card from a snake! + + Created by juan and Someone during the first code jam. + """ + # Get the snake data we need + if not name: + name_obj = await self._get_snake_name() + name = name_obj['scientific'] + content = await self._get_snek(name) + + elif isinstance(name, dict): + content = name + + else: + content = await self._get_snek(name) + + # Make the card + async with ctx.typing(): + + stream = BytesIO() + async with async_timeout.timeout(10): + async with self.bot.http_session.get(content['image_list'][0]) as response: + stream.write(await response.read()) + + stream.seek(0) + + func = partial(self._generate_card, stream, content) + final_buffer = await self.bot.loop.run_in_executor(None, func) + + # Send it! + await ctx.send( + f"A wild {content['name'].title()} appears!", + file=File(final_buffer, filename=content['name'].replace(" ", "") + ".png") + ) + + @snakes_group.command(name='fact') + async def fact_command(self, ctx: Context): + """ + Gets a snake-related fact + + Written by Andrew and Prithaj. + Modified by lemon. + """ + question = random.choice(self.snake_facts)["fact"] + embed = Embed( + title="Snake fact", + color=SNAKE_COLOR, + description=question + ) + await ctx.channel.send(embed=embed) + + @snakes_group.command(name='help') + async def help_command(self, ctx: Context): + """ + This just invokes the help command on this cog. + """ + log.debug(f"{ctx.author} requested info about the snakes cog") + return await ctx.invoke(self.bot.get_command("help"), "Snakes") + + @snakes_group.command(name='snakify') + async def snakify_command(self, ctx: Context, *, message: str = None): + """ + How would I talk if I were a snake? + :param ctx: context + :param message: If this is passed, it will snakify the message. + If not, it will snakify a random message from + the users history. + + Written by Momo and kel. + Modified by lemon. + """ + with ctx.typing(): + embed = Embed() + user = ctx.message.author + + if not message: + + # Get a random message from the users history + messages = [] + async for message in ctx.channel.history(limit=500).filter( + lambda msg: msg.author == ctx.message.author # Message was sent by author. + ): + messages.append(message.content) + + message = self._get_random_long_message(messages) + + # Set the avatar + if user.avatar is not None: + avatar = f"https://cdn.discordapp.com/avatars/{user.id}/{user.avatar}" + else: + avatar = ctx.author.default_avatar_url + + # Build and send the embed + embed.set_author( + name=f"{user.name}#{user.discriminator}", + icon_url=avatar, + ) + embed.description = f"*{self._snakify(message)}*" + + await ctx.channel.send(embed=embed) + + @snakes_group.command(name='video', aliases=('get_video',)) + async def video_command(self, ctx: Context, *, search: str = None): + """ + Gets a YouTube video about snakes + + :param ctx: Context object passed from discord.py + :param search: Optional, a name of a snake. Used to search for videos with that name + + Written by Andrew and Prithaj. + """ + # Are we searching for anything specific? + if search: + query = search + ' snake' + else: + snake = await self._get_snake_name() + query = snake['name'] + + # Build the URL and make the request + url = f'https://www.googleapis.com/youtube/v3/search' + response = await self.bot.http_session.get( + url, + params={ + "part": "snippet", + "q": urllib.parse.quote(query), + "type": "video", + "key": Tokens.youtube + } + ) + response = await response.json() + data = response['items'] + + # Send the user a video + if len(data) > 0: + num = random.randint(0, len(data) - 1) + youtube_base_url = 'https://www.youtube.com/watch?v=' + await ctx.channel.send( + content=f"{youtube_base_url}{data[num]['id']['videoId']}" + ) + else: + log.warning(f"YouTube API error. Full response looks like {response}") + + @snakes_group.command(name='zen') + async def zen_command(self, ctx: Context): + """ + Gets a random quote from the Zen of Python, + except as if spoken by a snake. + + Written by Prithaj and Andrew. + Modified by lemon. + """ + embed = Embed( + title="Zzzen of Pythhon", + color=SNAKE_COLOR + ) + + # Get the zen quote and snakify it + zen_quote = random.choice(ZEN.splitlines()) + zen_quote = self._snakify(zen_quote) + + # Embed and send + embed.description = zen_quote + await ctx.channel.send( + embed=embed + ) + # endregion + + # region: Error handlers + @get_command.error + @card_command.error + @video_command.error + async def command_error(self, ctx, error): + + embed = Embed() + embed.colour = Colour.red() + + if isinstance(error, BadArgument): + embed.description = str(error) + embed.title = random.choice(ERROR_REPLIES) + + elif isinstance(error, OSError): + log.error(f"snake_card encountered an OSError: {error} ({error.original})") + embed.description = "Could not generate the snake card! Please try again." + embed.title = random.choice(ERROR_REPLIES) + + else: + log.error(f"Unhandled tag command error: {error} ({error.original})") + return + + await ctx.send(embed=embed) + # endregion diff --git a/bot/seasons/evergreen/snakes/utils.py b/bot/seasons/evergreen/snakes/utils.py new file mode 100644 index 00000000..ec280223 --- /dev/null +++ b/bot/seasons/evergreen/snakes/utils.py @@ -0,0 +1,700 @@ +""" +Perlin noise implementation. +Taken from: https://gist.github.com/eevee/26f547457522755cb1fb8739d0ea89a1 +Licensed under ISC +""" +import asyncio +import io +import json +import logging +import math +import random +from itertools import product +from pathlib import Path +from typing import List, Tuple + +import aiohttp +from PIL import Image +from PIL.ImageDraw import ImageDraw +from discord import File, Member, Reaction +from discord.ext.commands import Context + +SNAKE_RESOURCES = Path('bot', 'resources', 'snakes').absolute() + +h1 = r'''``` + ---- + ------ + /--------\ + |--------| + |--------| + \------/ + ----```''' +h2 = r'''``` + ---- + ------ + /---\-/--\ + |-----\--| + |--------| + \------/ + ----```''' +h3 = r'''``` + ---- + ------ + /---\-/--\ + |-----\--| + |-----/--| + \----\-/ + ----```''' +h4 = r'''``` + ----- + ----- \ + /--| /---\ + |--\ -\---| + |--\--/-- / + \------- / + ------```''' +stages = [h1, h2, h3, h4] +snakes = { + "Baby Python": "https://i.imgur.com/SYOcmSa.png", + "Baby Rattle Snake": "https://i.imgur.com/i5jYA8f.png", + "Baby Dragon Snake": "https://i.imgur.com/SuMKM4m.png", + "Baby Garden Snake": "https://i.imgur.com/5vYx3ah.png", + "Baby Cobra": "https://i.imgur.com/jk14ryt.png" +} + +BOARD_TILE_SIZE = 56 # the size of each board tile +BOARD_PLAYER_SIZE = 20 # the size of each player icon +BOARD_MARGIN = (10, 0) # margins, in pixels (for player icons) +# The size of the image to download +# Should a power of 2 and higher than BOARD_PLAYER_SIZE +PLAYER_ICON_IMAGE_SIZE = 32 +MAX_PLAYERS = 4 # depends on the board size/quality, 4 is for the default board + +# board definition (from, to) +BOARD = { + # ladders + 2: 38, + 7: 14, + 8: 31, + 15: 26, + 21: 42, + 28: 84, + 36: 44, + 51: 67, + 71: 91, + 78: 98, + 87: 94, + + # snakes + 99: 80, + 95: 75, + 92: 88, + 89: 68, + 74: 53, + 64: 60, + 62: 19, + 49: 11, + 46: 25, + 16: 6 +} + +DEFAULT_SNAKE_COLOR: int = 0x15c7ea +DEFAULT_BACKGROUND_COLOR: int = 0 +DEFAULT_IMAGE_DIMENSIONS: Tuple[int] = (200, 200) +DEFAULT_SNAKE_LENGTH: int = 22 +DEFAULT_SNAKE_WIDTH: int = 8 +DEFAULT_SEGMENT_LENGTH_RANGE: Tuple[int] = (7, 10) +DEFAULT_IMAGE_MARGINS: Tuple[int] = (50, 50) +DEFAULT_TEXT: str = "snek\nit\nup" +DEFAULT_TEXT_POSITION: Tuple[int] = ( + 10, + 10 +) +DEFAULT_TEXT_COLOR: int = 0xf2ea15 +X = 0 +Y = 1 +ANGLE_RANGE = math.pi * 2 + + +def get_resource(file: str) -> List[dict]: + with (SNAKE_RESOURCES / f"{file}.json").open(encoding="utf-8") as snakefile: + return json.load(snakefile) + + +def smoothstep(t): + """Smooth curve with a zero derivative at 0 and 1, making it useful for + interpolating. + """ + return t * t * (3. - 2. * t) + + +def lerp(t, a, b): + """Linear interpolation between a and b, given a fraction t.""" + return a + t * (b - a) + + +class PerlinNoiseFactory(object): + """Callable that produces Perlin noise for an arbitrary point in an + arbitrary number of dimensions. The underlying grid is aligned with the + integers. + There is no limit to the coordinates used; new gradients are generated on + the fly as necessary. + """ + + def __init__(self, dimension, octaves=1, tile=(), unbias=False): + """Create a new Perlin noise factory in the given number of dimensions, + which should be an integer and at least 1. + More octaves create a foggier and more-detailed noise pattern. More + than 4 octaves is rather excessive. + ``tile`` can be used to make a seamlessly tiling pattern. For example: + pnf = PerlinNoiseFactory(2, tile=(0, 3)) + This will produce noise that tiles every 3 units vertically, but never + tiles horizontally. + If ``unbias`` is true, the smoothstep function will be applied to the + output before returning it, to counteract some of Perlin noise's + significant bias towards the center of its output range. + """ + self.dimension = dimension + self.octaves = octaves + self.tile = tile + (0,) * dimension + self.unbias = unbias + + # For n dimensions, the range of Perlin noise is ±sqrt(n)/2; multiply + # by this to scale to ±1 + self.scale_factor = 2 * dimension ** -0.5 + + self.gradient = {} + + def _generate_gradient(self): + # Generate a random unit vector at each grid point -- this is the + # "gradient" vector, in that the grid tile slopes towards it + + # 1 dimension is special, since the only unit vector is trivial; + # instead, use a slope between -1 and 1 + if self.dimension == 1: + return (random.uniform(-1, 1),) + + # Generate a random point on the surface of the unit n-hypersphere; + # this is the same as a random unit vector in n dimensions. Thanks + # to: http://mathworld.wolfram.com/SpherePointPicking.html + # Pick n normal random variables with stddev 1 + random_point = [random.gauss(0, 1) for _ in range(self.dimension)] + # Then scale the result to a unit vector + scale = sum(n * n for n in random_point) ** -0.5 + return tuple(coord * scale for coord in random_point) + + def get_plain_noise(self, *point): + """Get plain noise for a single point, without taking into account + either octaves or tiling. + """ + if len(point) != self.dimension: + raise ValueError("Expected {0} values, got {1}".format( + self.dimension, len(point))) + + # Build a list of the (min, max) bounds in each dimension + grid_coords = [] + for coord in point: + min_coord = math.floor(coord) + max_coord = min_coord + 1 + grid_coords.append((min_coord, max_coord)) + + # Compute the dot product of each gradient vector and the point's + # distance from the corresponding grid point. This gives you each + # gradient's "influence" on the chosen point. + dots = [] + for grid_point in product(*grid_coords): + if grid_point not in self.gradient: + self.gradient[grid_point] = self._generate_gradient() + gradient = self.gradient[grid_point] + + dot = 0 + for i in range(self.dimension): + dot += gradient[i] * (point[i] - grid_point[i]) + dots.append(dot) + + # Interpolate all those dot products together. The interpolation is + # done with smoothstep to smooth out the slope as you pass from one + # grid cell into the next. + # Due to the way product() works, dot products are ordered such that + # the last dimension alternates: (..., min), (..., max), etc. So we + # can interpolate adjacent pairs to "collapse" that last dimension. Then + # the results will alternate in their second-to-last dimension, and so + # forth, until we only have a single value left. + dim = self.dimension + while len(dots) > 1: + dim -= 1 + s = smoothstep(point[dim] - grid_coords[dim][0]) + + next_dots = [] + while dots: + next_dots.append(lerp(s, dots.pop(0), dots.pop(0))) + + dots = next_dots + + return dots[0] * self.scale_factor + + def __call__(self, *point): + """Get the value of this Perlin noise function at the given point. The + number of values given should match the number of dimensions. + """ + ret = 0 + for o in range(self.octaves): + o2 = 1 << o + new_point = [] + for i, coord in enumerate(point): + coord *= o2 + if self.tile[i]: + coord %= self.tile[i] * o2 + new_point.append(coord) + ret += self.get_plain_noise(*new_point) / o2 + + # Need to scale n back down since adding all those extra octaves has + # probably expanded it beyond ±1 + # 1 octave: ±1 + # 2 octaves: ±1½ + # 3 octaves: ±1¾ + ret /= 2 - 2 ** (1 - self.octaves) + + if self.unbias: + # The output of the plain Perlin noise algorithm has a fairly + # strong bias towards the center due to the central limit theorem + # -- in fact the top and bottom 1/8 virtually never happen. That's + # a quarter of our entire output range! If only we had a function + # in [0..1] that could introduce a bias towards the endpoints... + r = (ret + 1) / 2 + # Doing it this many times is a completely made-up heuristic. + for _ in range(int(self.octaves / 2 + 0.5)): + r = smoothstep(r) + ret = r * 2 - 1 + + return ret + + +def create_snek_frame( + perlin_factory: PerlinNoiseFactory, perlin_lookup_vertical_shift: float = 0, + image_dimensions: Tuple[int] = DEFAULT_IMAGE_DIMENSIONS, image_margins: Tuple[int] = DEFAULT_IMAGE_MARGINS, + snake_length: int = DEFAULT_SNAKE_LENGTH, + snake_color: int = DEFAULT_SNAKE_COLOR, bg_color: int = DEFAULT_BACKGROUND_COLOR, + segment_length_range: Tuple[int] = DEFAULT_SEGMENT_LENGTH_RANGE, snake_width: int = DEFAULT_SNAKE_WIDTH, + text: str = DEFAULT_TEXT, text_position: Tuple[int] = DEFAULT_TEXT_POSITION, + text_color: Tuple[int] = DEFAULT_TEXT_COLOR +) -> Image: + """ + Creates a single random snek frame using Perlin noise. + :param perlin_factory: the perlin noise factory used. Required. + :param perlin_lookup_vertical_shift: the Perlin noise shift in the Y-dimension for this frame + :param image_dimensions: the size of the output image. + :param image_margins: the margins to respect inside of the image. + :param snake_length: the length of the snake, in segments. + :param snake_color: the color of the snake. + :param bg_color: the background color. + :param segment_length_range: the range of the segment length. Values will be generated inside this range, including + the bounds. + :param snake_width: the width of the snek, in pixels. + :param text: the text to display with the snek. Set to None for no text. + :param text_position: the position of the text. + :param text_color: the color of the text. + :return: a PIL image, representing a single frame. + """ + start_x = random.randint(image_margins[X], image_dimensions[X] - image_margins[X]) + start_y = random.randint(image_margins[Y], image_dimensions[Y] - image_margins[Y]) + points = [(start_x, start_y)] + + for index in range(0, snake_length): + angle = perlin_factory.get_plain_noise( + ((1 / (snake_length + 1)) * (index + 1)) + perlin_lookup_vertical_shift + ) * ANGLE_RANGE + current_point = points[index] + segment_length = random.randint(segment_length_range[0], segment_length_range[1]) + points.append(( + current_point[X] + segment_length * math.cos(angle), + current_point[Y] + segment_length * math.sin(angle) + )) + + # normalize bounds + min_dimensions = [start_x, start_y] + max_dimensions = [start_x, start_y] + for point in points: + min_dimensions[X] = min(point[X], min_dimensions[X]) + min_dimensions[Y] = min(point[Y], min_dimensions[Y]) + max_dimensions[X] = max(point[X], max_dimensions[X]) + max_dimensions[Y] = max(point[Y], max_dimensions[Y]) + + # shift towards middle + dimension_range = (max_dimensions[X] - min_dimensions[X], max_dimensions[Y] - min_dimensions[Y]) + shift = ( + image_dimensions[X] / 2 - (dimension_range[X] / 2 + min_dimensions[X]), + image_dimensions[Y] / 2 - (dimension_range[Y] / 2 + min_dimensions[Y]) + ) + + image = Image.new(mode='RGB', size=image_dimensions, color=bg_color) + draw = ImageDraw(image) + for index in range(1, len(points)): + point = points[index] + previous = points[index - 1] + draw.line( + ( + shift[X] + previous[X], + shift[Y] + previous[Y], + shift[X] + point[X], + shift[Y] + point[Y] + ), + width=snake_width, + fill=snake_color + ) + if text is not None: + draw.multiline_text(text_position, text, fill=text_color) + del draw + return image + + +def frame_to_png_bytes(image: Image): + stream = io.BytesIO() + image.save(stream, format='PNG') + return stream.getvalue() + + +log = logging.getLogger(__name__) +START_EMOJI = "\u2611" # :ballot_box_with_check: - Start the game +CANCEL_EMOJI = "\u274C" # :x: - Cancel or leave the game +ROLL_EMOJI = "\U0001F3B2" # :game_die: - Roll the die! +JOIN_EMOJI = "\U0001F64B" # :raising_hand: - Join the game. +STARTUP_SCREEN_EMOJI = [ + JOIN_EMOJI, + START_EMOJI, + CANCEL_EMOJI +] +GAME_SCREEN_EMOJI = [ + ROLL_EMOJI, + CANCEL_EMOJI +] + + +class SnakeAndLaddersGame: + def __init__(self, snakes, context: Context): + self.snakes = snakes + self.ctx = context + self.channel = self.ctx.channel + self.state = 'booting' + self.started = False + self.author = self.ctx.author + self.players = [] + self.player_tiles = {} + self.round_has_rolled = {} + self.avatar_images = {} + self.board = None + self.positions = None + self.rolls = [] + + async def open_game(self): + """ + Create a new Snakes and Ladders game. + + Listen for reactions until players have joined, + and the game has been started. + """ + def startup_event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + return ( + all(( + reaction_.message.id == startup.id, # Reaction is on startup message + reaction_.emoji in STARTUP_SCREEN_EMOJI, # Reaction is one of the startup emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + # Check to see if the bot can remove reactions + if not self.channel.permissions_for(self.ctx.guild.me).manage_messages: + log.warning( + "Unable to start Snakes and Ladders - " + f"Missing manage_messages permissions in {self.channel}" + ) + return + + await self._add_player(self.author) + await self.channel.send( + "**Snakes and Ladders**: A new game is about to start!", + file=File( + str(SNAKE_RESOURCES / "snakes_and_ladders" / "banner.jpg"), + # os.path.join("bot", "resources", "snakes", "snakes_and_ladders", "banner.jpg"), + filename='Snakes and Ladders.jpg' + ) + ) + startup = await self.channel.send( + f"Press {JOIN_EMOJI} to participate, and press " + f"{START_EMOJI} to start the game" + ) + for emoji in STARTUP_SCREEN_EMOJI: + await startup.add_reaction(emoji) + + self.state = 'waiting' + + while not self.started: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=startup_event_check + ) + if reaction.emoji == JOIN_EMOJI: + await self.player_join(user) + elif reaction.emoji == CANCEL_EMOJI: + if self.ctx.author == user: + await self.cancel_game(user) + return + else: + await self.player_leave(user) + elif reaction.emoji == START_EMOJI: + if self.ctx.author == user: + self.started = True + await self.start_game(user) + await startup.delete() + break + + await startup.remove_reaction(reaction.emoji, user) + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + self.cancel_game(self.author) + return # We're done, no reactions for the last 5 minutes + + async def _add_player(self, user: Member): + self.players.append(user) + self.player_tiles[user.id] = 1 + avatar_url = user.avatar_url_as(format='jpeg', size=PLAYER_ICON_IMAGE_SIZE) + async with aiohttp.ClientSession() as session: + async with session.get(avatar_url) as res: + avatar_bytes = await res.read() + im = Image.open(io.BytesIO(avatar_bytes)).resize((BOARD_PLAYER_SIZE, BOARD_PLAYER_SIZE)) + self.avatar_images[user.id] = im + + async def player_join(self, user: Member): + for p in self.players: + if user == p: + await self.channel.send(user.mention + " You are already in the game.", delete_after=10) + return + if self.state != 'waiting': + await self.channel.send(user.mention + " You cannot join at this time.", delete_after=10) + return + if len(self.players) is MAX_PLAYERS: + await self.channel.send(user.mention + " The game is full!", delete_after=10) + return + + await self._add_player(user) + + await self.channel.send( + f"**Snakes and Ladders**: {user.mention} has joined the game.\n" + f"There are now {str(len(self.players))} players in the game.", + delete_after=10 + ) + + async def player_leave(self, user: Member): + if user == self.author: + await self.channel.send( + user.mention + " You are the author, and cannot leave the game. Execute " + "`sal cancel` to cancel the game.", + delete_after=10 + ) + return + for p in self.players: + if user == p: + self.players.remove(p) + self.player_tiles.pop(p.id, None) + self.round_has_rolled.pop(p.id, None) + await self.channel.send( + "**Snakes and Ladders**: " + user.mention + " has left the game.", + delete_after=10 + ) + + if self.state != 'waiting' and len(self.players) == 1: + await self.channel.send("**Snakes and Ladders**: The game has been surrendered!") + self._destruct() + return + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + + async def cancel_game(self, user: Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can cancel it.", delete_after=10) + return + await self.channel.send("**Snakes and Ladders**: Game has been canceled.") + self._destruct() + + async def start_game(self, user: Member): + if not user == self.author: + await self.channel.send(user.mention + " Only the author of the game can start it.", delete_after=10) + return + if len(self.players) < 1: + await self.channel.send( + user.mention + " A minimum of 2 players is required to start the game.", + delete_after=10 + ) + return + if not self.state == 'waiting': + await self.channel.send(user.mention + " The game cannot be started at this time.", delete_after=10) + return + self.state = 'starting' + player_list = ', '.join(user.mention for user in self.players) + await self.channel.send("**Snakes and Ladders**: The game is starting!\nPlayers: " + player_list) + await self.start_round() + + async def start_round(self): + def game_event_check(reaction_: Reaction, user_: Member): + """ + Make sure that this reaction is what we want to operate on + """ + return ( + all(( + reaction_.message.id == self.positions.id, # Reaction is on positions message + reaction_.emoji in GAME_SCREEN_EMOJI, # Reaction is one of the game emotes + user_.id != self.ctx.bot.user.id, # Reaction was not made by the bot + )) + ) + + self.state = 'roll' + for user in self.players: + self.round_has_rolled[user.id] = False + # board_img = Image.open(os.path.join( + # "bot", "resources", "snakes", "snakes_and_ladders", "board.jpg")) + board_img = Image.open(str(SNAKE_RESOURCES / "snakes_and_ladders" / "board.jpg")) + player_row_size = math.ceil(MAX_PLAYERS / 2) + + for i, player in enumerate(self.players): + tile = self.player_tiles[player.id] + tile_coordinates = self._board_coordinate_from_index(tile) + x_offset = BOARD_MARGIN[0] + tile_coordinates[0] * BOARD_TILE_SIZE + y_offset = \ + BOARD_MARGIN[1] + ( + (10 * BOARD_TILE_SIZE) - (9 - tile_coordinates[1]) * BOARD_TILE_SIZE - BOARD_PLAYER_SIZE) + x_offset += BOARD_PLAYER_SIZE * (i % player_row_size) + y_offset -= BOARD_PLAYER_SIZE * math.floor(i / player_row_size) + board_img.paste(self.avatar_images[player.id], + box=(x_offset, y_offset)) + stream = io.BytesIO() + board_img.save(stream, format='JPEG') + board_file = File(stream.getvalue(), filename='Board.jpg') + player_list = '\n'.join((user.mention + ": Tile " + str(self.player_tiles[user.id])) for user in self.players) + + # Store and send new messages + temp_board = await self.channel.send( + "**Snakes and Ladders**: A new round has started! Current board:", + file=board_file + ) + temp_positions = await self.channel.send( + f"**Current positions**:\n{player_list}\n\nUse {ROLL_EMOJI} to roll the dice!" + ) + + # Delete the previous messages + if self.board and self.positions: + await self.board.delete() + await self.positions.delete() + + # remove the roll messages + for roll in self.rolls: + await roll.delete() + self.rolls = [] + + # Save new messages + self.board = temp_board + self.positions = temp_positions + + # Wait for rolls + for emoji in GAME_SCREEN_EMOJI: + await self.positions.add_reaction(emoji) + + while True: + try: + reaction, user = await self.ctx.bot.wait_for( + "reaction_add", + timeout=300, + check=game_event_check + ) + + if reaction.emoji == ROLL_EMOJI: + await self.player_roll(user) + elif reaction.emoji == CANCEL_EMOJI: + if self.ctx.author == user: + await self.cancel_game(user) + return + else: + await self.player_leave(user) + + await self.positions.remove_reaction(reaction.emoji, user) + + if self._check_all_rolled(): + break + + except asyncio.TimeoutError: + log.debug("Snakes and Ladders timed out waiting for a reaction") + await self.cancel_game(self.author) + return # We're done, no reactions for the last 5 minutes + + # Round completed + await self._complete_round() + + async def player_roll(self, user: Member): + if user.id not in self.player_tiles: + await self.channel.send(user.mention + " You are not in the match.", delete_after=10) + return + if self.state != 'roll': + await self.channel.send(user.mention + " You may not roll at this time.", delete_after=10) + return + if self.round_has_rolled[user.id]: + return + roll = random.randint(1, 6) + self.rolls.append(await self.channel.send(f"{user.mention} rolled a **{roll}**!")) + next_tile = self.player_tiles[user.id] + roll + + # apply snakes and ladders + if next_tile in BOARD: + target = BOARD[next_tile] + if target < next_tile: + await self.channel.send( + f"{user.mention} slips on a snake and falls back to **{target}**", + delete_after=15 + ) + else: + await self.channel.send( + f"{user.mention} climbs a ladder to **{target}**", + delete_after=15 + ) + next_tile = target + + self.player_tiles[user.id] = min(100, next_tile) + self.round_has_rolled[user.id] = True + + async def _complete_round(self): + self.state = 'post_round' + + # check for winner + winner = self._check_winner() + if winner is None: + # there is no winner, start the next round + await self.start_round() + return + + # announce winner and exit + await self.channel.send("**Snakes and Ladders**: " + winner.mention + " has won the game! :tada:") + self._destruct() + + def _check_winner(self) -> Member: + if self.state != 'post_round': + return None + return next((player for player in self.players if self.player_tiles[player.id] == 100), + None) + + def _check_all_rolled(self): + return all(rolled for rolled in self.round_has_rolled.values()) + + def _destruct(self): + del self.snakes.active_sal[self.channel] + + def _board_coordinate_from_index(self, index: int): + # converts the tile number to the x/y coordinates for graphical purposes + y_level = 9 - math.floor((index - 1) / 10) + is_reversed = math.floor((index - 1) / 10) % 2 != 0 + x_level = (index - 1) % 10 + if is_reversed: + x_level = 9 - x_level + return x_level, y_level diff --git a/bot/seasons/evergreen/uptime.py b/bot/seasons/evergreen/uptime.py index 1321da19..3d2c7d03 100644 --- a/bot/seasons/evergreen/uptime.py +++ b/bot/seasons/evergreen/uptime.py @@ -9,7 +9,7 @@ from bot import start_time log = logging.getLogger(__name__) -class Uptime: +class Uptime(commands.Cog): """ A cog for posting the bots uptime. """ @@ -35,4 +35,4 @@ class Uptime: # Required in order to load the cog, use the class name in the add_cog function. def setup(bot): bot.add_cog(Uptime(bot)) - log.debug("Uptime cog loaded") + log.info("Uptime cog loaded") diff --git a/bot/seasons/halloween/candy_collection.py b/bot/seasons/halloween/candy_collection.py index 80f30a1b..6932097c 100644 --- a/bot/seasons/halloween/candy_collection.py +++ b/bot/seasons/halloween/candy_collection.py @@ -20,7 +20,7 @@ ADD_SKULL_REACTION_CHANCE = 50 # 2% ADD_SKULL_EXISTING_REACTION_CHANCE = 20 # 5% -class CandyCollection: +class CandyCollection(commands.Cog): def __init__(self, bot): self.bot = bot with open(json_location) as candy: @@ -31,6 +31,7 @@ class CandyCollection: userid = userinfo['userid'] self.get_candyinfo[userid] = userinfo + @commands.Cog.listener() async def on_message(self, message): """ Randomly adds candy or skull to certain messages @@ -54,6 +55,7 @@ class CandyCollection: self.msg_reacted.append(d) return await message.add_reaction('\N{CANDY}') + @commands.Cog.listener() async def on_reaction_add(self, reaction, user): """ Add/remove candies from a person if the reaction satisfies criteria @@ -231,4 +233,4 @@ class CandyCollection: def setup(bot): bot.add_cog(CandyCollection(bot)) - log.debug("CandyCollection cog loaded") + log.info("CandyCollection cog loaded") diff --git a/bot/seasons/halloween/hacktoberstats.py b/bot/seasons/halloween/hacktoberstats.py index 41cf10ee..81f11455 100644 --- a/bot/seasons/halloween/hacktoberstats.py +++ b/bot/seasons/halloween/hacktoberstats.py @@ -13,7 +13,7 @@ from discord.ext import commands log = logging.getLogger(__name__) -class HacktoberStats: +class HacktoberStats(commands.Cog): def __init__(self, bot): self.bot = bot self.link_json = Path("bot", "resources", "github_links.json") @@ -332,4 +332,4 @@ class HacktoberStats: def setup(bot): bot.add_cog(HacktoberStats(bot)) - log.debug("HacktoberStats cog loaded") + log.info("HacktoberStats cog loaded") diff --git a/bot/seasons/halloween/halloween_facts.py b/bot/seasons/halloween/halloween_facts.py index 098ee432..9224cc57 100644 --- a/bot/seasons/halloween/halloween_facts.py +++ b/bot/seasons/halloween/halloween_facts.py @@ -25,7 +25,7 @@ PUMPKIN_ORANGE = discord.Color(0xFF7518) INTERVAL = timedelta(hours=6).total_seconds() -class HalloweenFacts: +class HalloweenFacts(commands.Cog): def __init__(self, bot): self.bot = bot @@ -35,6 +35,7 @@ class HalloweenFacts: self.facts = list(enumerate(self.halloween_facts)) random.shuffle(self.facts) + @commands.Cog.listener() async def on_ready(self): self.channel = self.bot.get_channel(Hacktoberfest.channel_id) self.bot.loop.create_task(self._fact_publisher_task()) @@ -63,4 +64,4 @@ class HalloweenFacts: def setup(bot): bot.add_cog(HalloweenFacts(bot)) - log.debug("HalloweenFacts cog loaded") + log.info("HalloweenFacts cog loaded") diff --git a/bot/seasons/halloween/halloweenify.py b/bot/seasons/halloween/halloweenify.py index cda07472..0d6964a5 100644 --- a/bot/seasons/halloween/halloweenify.py +++ b/bot/seasons/halloween/halloweenify.py @@ -10,7 +10,7 @@ from discord.ext.commands.cooldowns import BucketType log = logging.getLogger(__name__) -class Halloweenify: +class Halloweenify(commands.Cog): """ A cog to change a invokers nickname to a spooky one! """ @@ -52,4 +52,4 @@ class Halloweenify: def setup(bot): bot.add_cog(Halloweenify(bot)) - log.debug("Halloweenify cog loaded") + log.info("Halloweenify cog loaded") diff --git a/bot/seasons/halloween/monstersurvey.py b/bot/seasons/halloween/monstersurvey.py index 08873f24..2b251b90 100644 --- a/bot/seasons/halloween/monstersurvey.py +++ b/bot/seasons/halloween/monstersurvey.py @@ -4,7 +4,7 @@ import os from discord import Embed from discord.ext import commands -from discord.ext.commands import Bot, Context +from discord.ext.commands import Bot, Cog, Context log = logging.getLogger(__name__) @@ -14,7 +14,7 @@ EMOJIS = { } -class MonsterSurvey: +class MonsterSurvey(Cog): """ Vote for your favorite monster! This command allows users to vote for their favorite listed monster. @@ -215,4 +215,4 @@ class MonsterSurvey: def setup(bot): bot.add_cog(MonsterSurvey(bot)) - log.debug("MonsterSurvey cog loaded") + log.info("MonsterSurvey cog loaded") diff --git a/bot/seasons/halloween/scarymovie.py b/bot/seasons/halloween/scarymovie.py index b280781e..dcff4f58 100644 --- a/bot/seasons/halloween/scarymovie.py +++ b/bot/seasons/halloween/scarymovie.py @@ -13,7 +13,7 @@ TMDB_API_KEY = environ.get('TMDB_API_KEY') TMDB_TOKEN = environ.get('TMDB_TOKEN') -class ScaryMovie: +class ScaryMovie(commands.Cog): """ Selects a random scary movie and embeds info into discord chat """ @@ -138,4 +138,4 @@ class ScaryMovie: def setup(bot): bot.add_cog(ScaryMovie(bot)) - log.debug("ScaryMovie cog loaded") + log.info("ScaryMovie cog loaded") diff --git a/bot/seasons/halloween/spookyavatar.py b/bot/seasons/halloween/spookyavatar.py index b37a03f9..032ad352 100644 --- a/bot/seasons/halloween/spookyavatar.py +++ b/bot/seasons/halloween/spookyavatar.py @@ -4,15 +4,15 @@ from io import BytesIO import aiohttp import discord -from discord.ext import commands from PIL import Image +from discord.ext import commands from bot.utils.halloween import spookifications log = logging.getLogger(__name__) -class SpookyAvatar: +class SpookyAvatar(commands.Cog): """ A cog that spookifies an avatar. @@ -55,4 +55,4 @@ class SpookyAvatar: def setup(bot): bot.add_cog(SpookyAvatar(bot)) - log.debug("SpookyAvatar cog loaded") + log.info("SpookyAvatar cog loaded") diff --git a/bot/seasons/halloween/spookygif.py b/bot/seasons/halloween/spookygif.py index 1233773b..c11d5ecb 100644 --- a/bot/seasons/halloween/spookygif.py +++ b/bot/seasons/halloween/spookygif.py @@ -9,7 +9,7 @@ from bot.constants import Tokens log = logging.getLogger(__name__) -class SpookyGif: +class SpookyGif(commands.Cog): """ A cog to fetch a random spooky gif from the web! """ @@ -40,4 +40,4 @@ class SpookyGif: def setup(bot): bot.add_cog(SpookyGif(bot)) - log.debug("SpookyGif cog loaded") + log.info("SpookyGif cog loaded") diff --git a/bot/seasons/halloween/spookyreact.py b/bot/seasons/halloween/spookyreact.py index f63cd7e5..3b4e3fdf 100644 --- a/bot/seasons/halloween/spookyreact.py +++ b/bot/seasons/halloween/spookyreact.py @@ -2,6 +2,7 @@ import logging import re import discord +from discord.ext.commands import Cog log = logging.getLogger(__name__) @@ -16,7 +17,7 @@ SPOOKY_TRIGGERS = { } -class SpookyReact: +class SpookyReact(Cog): """ A cog that makes the bot react to message triggers. @@ -25,6 +26,7 @@ class SpookyReact: def __init__(self, bot): self.bot = bot + @Cog.listener() async def on_message(self, ctx: discord.Message): """ A command to send the seasonalbot github project @@ -69,4 +71,4 @@ class SpookyReact: def setup(bot): bot.add_cog(SpookyReact(bot)) - log.debug("SpookyReact cog loaded") + log.info("SpookyReact cog loaded") diff --git a/bot/seasons/halloween/spookysound.py b/bot/seasons/halloween/spookysound.py index 4cab1239..1e430dab 100644 --- a/bot/seasons/halloween/spookysound.py +++ b/bot/seasons/halloween/spookysound.py @@ -10,7 +10,7 @@ from bot.constants import Hacktoberfest log = logging.getLogger(__name__) -class SpookySound: +class SpookySound(commands.Cog): """ A cog that plays a spooky sound in a voice channel on command. """ @@ -47,4 +47,4 @@ class SpookySound: def setup(bot): bot.add_cog(SpookySound(bot)) - log.debug("SpookySound cog loaded") + log.info("SpookySound cog loaded") diff --git a/bot/seasons/pride/__init__.py b/bot/seasons/pride/__init__.py new file mode 100644 index 00000000..d8a7e34b --- /dev/null +++ b/bot/seasons/pride/__init__.py @@ -0,0 +1,17 @@ +from bot.seasons import SeasonBase + + +class Pride(SeasonBase): + """ + No matter your origin, identity or sexuality, we come together to celebrate each and everyone's individuality. + Feature contributions to ProudBot is encouraged to commemorate the history and challenges of the LGBTQ+ community. + Happy Pride Month + """ + + name = "pride" + bot_name = "ProudBot" + greeting = "Happy Pride Month!" + + # Duration of season + start_date = "01/06" + end_date = "30/06" diff --git a/bot/seasons/season.py b/bot/seasons/season.py index e59949d7..b7892606 100644 --- a/bot/seasons/season.py +++ b/bot/seasons/season.py @@ -90,6 +90,7 @@ class SeasonBase: colour: Optional[int] = None icon: str = "/logos/logo_full/logo_full.png" + bot_icon: Optional[str] = None date_format: str = "%d/%m/%Y" @@ -151,10 +152,11 @@ class SeasonBase: return f"New Season, {self.name_clean}!" - async def get_icon(self) -> bytes: + async def get_icon(self, avatar: bool = False) -> bytes: """ Retrieves the icon image from the branding repository, using the - defined icon attribute for the season. + defined icon attribute for the season. If `avatar` is True, uses + optional bot-only avatar icon if present. The icon attribute must provide the url path, starting from the master branch base url, including the starting slash: @@ -162,7 +164,11 @@ class SeasonBase: """ base_url = "https://raw.githubusercontent.com/python-discord/branding/master" - full_url = base_url + self.icon + if avatar: + icon = self.bot_icon or self.icon + else: + icon = self.icon + full_url = base_url + icon log.debug(f"Getting icon from: {full_url}") async with bot.http_session.get(full_url) as resp: return await resp.read() @@ -217,17 +223,17 @@ class SeasonBase: old_avatar = bot.user.avatar # attempt the change - log.debug(f"Changing avatar to {self.icon}") - icon = await self.get_icon() + log.debug(f"Changing avatar to {self.bot_icon or self.icon}") + icon = await self.get_icon(avatar=True) with contextlib.suppress(discord.HTTPException, asyncio.TimeoutError): async with async_timeout.timeout(5): await bot.user.edit(avatar=icon) if bot.user.avatar != old_avatar: - log.debug(f"Avatar changed to {self.icon}") + log.debug(f"Avatar changed to {self.bot_icon or self.icon}") return True - log.warning(f"Changing avatar failed: {self.icon}") + log.warning(f"Changing avatar failed: {self.bot_icon or self.icon}") return False async def apply_server_icon(self) -> bool: @@ -334,18 +340,19 @@ class SeasonBase: if not Client.debug: log.info("Applying avatar.") await self.apply_avatar() - log.info("Applying server icon.") - await self.apply_server_icon() if username_changed: + log.info("Applying server icon.") + await self.apply_server_icon() log.info(f"Announcing season {self.name}.") await self.announce_season() else: + log.info(f"Skipping server icon change due to username not being changed.") log.info(f"Skipping season announcement due to username not being changed.") await bot.send_log("SeasonalBot Loaded!", f"Active Season: **{self.name_clean}**") -class SeasonManager: +class SeasonManager(commands.Cog): """ A cog for managing seasons. """ @@ -465,7 +472,7 @@ class SeasonManager: # report back details season_name = type(self.season).__name__ embed = discord.Embed( - description=f"**Season:** {season_name}\n**Avatar:** {self.season.icon}", + description=f"**Season:** {season_name}\n**Avatar:** {self.season.bot_icon or self.season.icon}", colour=colour ) embed.set_author(name=title) diff --git a/bot/seasons/valentines/be_my_valentine.py b/bot/seasons/valentines/be_my_valentine.py new file mode 100644 index 00000000..4e2182c3 --- /dev/null +++ b/bot/seasons/valentines/be_my_valentine.py @@ -0,0 +1,241 @@ +import logging +import random +import typing +from json import load +from pathlib import Path + +import discord +from discord.ext import commands +from discord.ext.commands.cooldowns import BucketType + +from bot.constants import Client, Colours, Lovefest + +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): + self.bot = bot + self.valentines = self.load_json() + + @staticmethod + def load_json(): + p = Path('bot', 'resources', 'valentines', 'bemyvalentine_valentines.json') + with p.open() as json_data: + valentines = load(json_data) + return valentines + + @commands.group(name="lovefest", invoke_without_command=True) + async def lovefest_role(self, ctx): + """ + You can have yourself the lovefest role or remove it. + 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. + """ + await ctx.invoke(self.bot.get_command("help"), "lovefest") + + @lovefest_role.command(name="sub") + async def add_role(self, ctx): + """ + This command adds the lovefest role. + """ + user = ctx.author + role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) + if Lovefest.role_id not in [role.id for role in ctx.message.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): + """ + This command removes the lovefest role. + """ + user = ctx.author + role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) + if Lovefest.role_id not in [role.id for role in ctx.message.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, BucketType.user) + @commands.group(name='bemyvalentine', invoke_without_command=True) + async def send_valentine(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): + """ + This command sends valentine to user if specified or a random user having lovefest role. + + syntax: .bemyvalentine [user](optional) [p/poem/c/compliment/or you can type your own valentine message] + (optional) + + example: .bemyvalentine (sends valentine as a poem or a compliment to a random user) + 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 + msg = "You are supposed to use this command in the server." + return await ctx.send(msg) + + if user: + if Lovefest.role_id not in [role.id for role in user.roles]: + message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" + return await ctx.send(message) + + if user == ctx.author: + # Well a user can't valentine himself/herself. + return await ctx.send("Come on dude, you can't send a valentine to yourself :expressionless:") + + emoji_1, emoji_2 = self.random_emoji() + lovefest_role = discord.utils.get(ctx.guild.roles, id=Lovefest.role_id) + channel = self.bot.get_channel(Lovefest.channel_id) + valentine, title = self.valentine_check(valentine_type) + + if user is None: + author = ctx.author + user = self.random_user(author, lovefest_role.members) + if user is None: + return await ctx.send("There are no users avilable to whome your valentine can be sent.") + + 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, BucketType.user) + @send_valentine.command(name='secret') + async def anonymous(self, ctx, user: typing.Optional[discord.Member] = None, *, valentine_type=None): + """ + This command DMs a valentine to be given anonymous to a user if specified or a random user having lovefest role. + + **This command should be DMed to the bot.** + + syntax : .bemyvalentine secret [user](optional) [p/poem/c/compliment/or you can type your own valentine message] + (optional) + + example : .bemyvalentine secret (sends valentine as a poem or a compliment to a random user in DM making you + anonymous) + 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 ctx.guild is not None: + # This command is only DM specific + msg = "You are not supposed to use this command in the server, DM the command to the bot." + return await ctx.send(msg) + + if user: + if Lovefest.role_id not in [role.id for role in user.roles]: + message = f"You cannot send a valentine to {user} as he/she does not have the lovefest role!" + return await ctx.send(message) + + if user == ctx.author: + # Well a user cant valentine himself/herself. + return await ctx.send('Come on dude, you cant send a valentine to yourself :expressionless:') + + guild = self.bot.get_guild(id=Client.guild) + emoji_1, emoji_2 = self.random_emoji() + lovefest_role = discord.utils.get(guild.roles, id=Lovefest.role_id) + valentine, title = self.valentine_check(valentine_type) + + if user is None: + author = ctx.author + user = self.random_user(author, lovefest_role.members) + if user is None: + return await ctx.send("There are no users avilable to whome your valentine can be sent.") + + 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 + ) + try: + await user.send(embed=embed) + except discord.Forbidden: + await ctx.author.send(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): + if valentine_type is None: + valentine, title = self.random_valentine() + + elif valentine_type.lower() in ['p', 'poem']: + valentine = self.valentine_poem() + title = 'A poem dedicated to' + + elif valentine_type.lower() in ['c', 'compliment']: + valentine = self.valentine_compliment() + title = 'A compliment for' + + else: + # in this case, the user decides to type his own valentine. + valentine = valentine_type + title = 'A message for' + return valentine, title + + @staticmethod + def random_user(author, members): + """ + Picks a random member from the list provided in `members`, ensuring + the author is not one of the options. + + :param author: member who invoked the command + :param members: list of discord.Member objects + """ + if author in members: + members.remove(author) + + return random.choice(members) if members else None + + @staticmethod + def random_emoji(): + EMOJI_1 = random.choice(HEART_EMOJIS) + EMOJI_2 = random.choice(HEART_EMOJIS) + return EMOJI_1, EMOJI_2 + + def random_valentine(self): + """ + 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): + """ + Grabs a random poem. + """ + valentine_poem = random.choice(self.valentines['valentine_poems']) + return valentine_poem + + def valentine_compliment(self): + """ + Grabs a random compliment. + """ + valentine_compliment = random.choice(self.valentines['valentine_compliments']) + return valentine_compliment + + +def setup(bot): + bot.add_cog(BeMyValentine(bot)) + log.info("BeMyValentine cog loaded") diff --git a/bot/seasons/valentines/lovecalculator.py b/bot/seasons/valentines/lovecalculator.py new file mode 100644 index 00000000..0662cf5b --- /dev/null +++ b/bot/seasons/valentines/lovecalculator.py @@ -0,0 +1,107 @@ +import bisect +import hashlib +import json +import logging +import random +from pathlib import Path +from typing import Union + +import discord +from discord import Member +from discord.ext import commands +from discord.ext.commands import BadArgument, Cog, clean_content + +from bot.constants import Roles + +log = logging.getLogger(__name__) + +with Path('bot', 'resources', 'valentines', 'love_matches.json').open() as file: + LOVE_DATA = json.load(file) + 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 + """ + + def __init__(self, bot): + self.bot = bot + + @commands.command(aliases=('love_calculator', 'love_calc')) + @commands.cooldown(rate=1, per=5, type=commands.BucketType.user) + async def love(self, ctx, who: Union[Member, str], whom: Union[Member, str] = None): + """ + Tells you how much the two love each other. + + This command accepts users or arbitrary strings as arguments. + Users are converted from: + - User ID + - Mention + - name#discrim + - name + - nickname + + Any two arguments will always yield the same result, though the order of arguments matters: + Running .love joseph erlang will always yield the same result. + Running .love erlang joseph won't yield the same result as .love joseph erlang + + If you want to use multiple words for one argument, you must include quotes. + .love "Zes Vappa" "morning coffee" + + If only one argument is provided, the subject will become one of the helpers at random. + """ + + if whom is None: + staff = ctx.guild.get_role(Roles.helpers).members + whom = random.choice(staff) + + def normalize(arg): + if isinstance(arg, Member): + # if we are given a member, return name#discrim without any extra changes + arg = str(arg) + else: + # otherwise normalise case and remove any leading/trailing whitespace + arg = arg.strip().title() + # this has to be done manually to be applied to usernames + return clean_content(escape_markdown=True).convert(ctx, arg) + + who, whom = [await normalize(arg) for arg in (who, whom)] + + # make sure user didn't provide something silly such as 10 spaces + if not (who and whom): + raise BadArgument('Arguments be non-empty strings.') + + # hash inputs to guarantee consistent results (hashing algorithm choice arbitrary) + # + # hashlib is used over the builtin hash() function + # 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'] + ) + + await ctx.send(embed=embed) + + +def setup(bot): + bot.add_cog(LoveCalculator(bot)) + log.info("LoveCalculator cog loaded") diff --git a/bot/seasons/valentines/movie_generator.py b/bot/seasons/valentines/movie_generator.py new file mode 100644 index 00000000..8fce011b --- /dev/null +++ b/bot/seasons/valentines/movie_generator.py @@ -0,0 +1,66 @@ +import logging +import random +from os import environ +from urllib import parse + +import discord +from discord.ext import commands + +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): + self.bot = bot + + @commands.command(name="romancemovie") + async def romance_movie(self, ctx): + """ + 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?" + parse.urlencode(params) + async with self.bot.http_session.get(request_url) 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"]) + 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.add_cog(RomanceMovieFinder(bot)) + log.info("RomanceMovieFinder cog loaded") diff --git a/bot/seasons/valentines/myvalenstate.py b/bot/seasons/valentines/myvalenstate.py new file mode 100644 index 00000000..7d9f3a59 --- /dev/null +++ b/bot/seasons/valentines/myvalenstate.py @@ -0,0 +1,85 @@ +import collections +import json +import logging +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path('bot', 'resources', 'valentines', 'valenstates.json'), 'r') as file: + STATES = json.load(file) + + +class MyValenstate(commands.Cog): + def __init__(self, bot): + self.bot = bot + + def levenshtein(self, source, goal): + """ + 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, *, name=None): + eq_chars = collections.defaultdict(int) + if name is None: + author = ctx.message.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[:len(matches)-2])}, and {matches[len(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!" + leftovers = str(matches) + embed_text = f"You have another match, this being {leftovers}." + 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=f'{STATES[valenstate]["text"]}', + colour=Colours.pink + ) + embed.add_field(name=embed_title, value=embed_text) + embed.set_image(url=STATES[valenstate]["flag"]) + await ctx.channel.send(embed=embed) + + +def setup(bot): + bot.add_cog(MyValenstate(bot)) + log.info("MyValenstate cog loaded") diff --git a/bot/seasons/valentines/pickuplines.py b/bot/seasons/valentines/pickuplines.py new file mode 100644 index 00000000..e1abb4e5 --- /dev/null +++ b/bot/seasons/valentines/pickuplines.py @@ -0,0 +1,44 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path('bot', 'resources', 'valentines', 'pickup_lines.json'), 'r', encoding="utf8") as f: + pickup_lines = load(f) + + +class PickupLine(commands.Cog): + """ + A cog that gives random cheesy pickup lines. + """ + + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def pickupline(self, ctx): + """ + 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.add_cog(PickupLine(bot)) + log.info('PickupLine cog loaded') diff --git a/bot/seasons/valentines/savethedate.py b/bot/seasons/valentines/savethedate.py new file mode 100644 index 00000000..fbc9eb82 --- /dev/null +++ b/bot/seasons/valentines/savethedate.py @@ -0,0 +1,45 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +HEART_EMOJIS = [":heart:", ":gift_heart:", ":revolving_hearts:", ":sparkling_heart:", ":two_hearts:"] + +with open(Path('bot', 'resources', 'valentines', 'date_ideas.json'), 'r', encoding="utf8") as f: + VALENTINES_DATES = load(f) + + +class SaveTheDate(commands.Cog): + """ + A cog that gives random suggestion, for a valentines date ! + """ + + def __init__(self, bot): + self.bot = bot + + @commands.command() + async def savethedate(self, ctx): + """ + 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.add_cog(SaveTheDate(bot)) + log.info("SaveTheDate cog loaded") diff --git a/bot/seasons/valentines/valentine_zodiac.py b/bot/seasons/valentines/valentine_zodiac.py new file mode 100644 index 00000000..33fc739a --- /dev/null +++ b/bot/seasons/valentines/valentine_zodiac.py @@ -0,0 +1,59 @@ +import logging +import random +from json import load +from pathlib import Path + +import discord +from discord.ext import commands + +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, bot): + self.bot = bot + self.zodiacs = self.load_json() + + @staticmethod + def load_json(): + p = Path('bot', 'resources', 'valentines', 'zodiac_compatibility.json') + with p.open() as json_data: + zodiacs = load(json_data) + return zodiacs + + @commands.command(name="partnerzodiac") + async def counter_zodiac(self, ctx, zodiac_sign): + """ + Provides a counter compatible zodiac sign to the given user's zodiac sign. + """ + try: + compatible_zodiac = random.choice(self.zodiacs[zodiac_sign.lower()]) + except KeyError: + return await ctx.send(zodiac_sign.capitalize() + " zodiac sign does not exist.") + + emoji1 = random.choice(HEART_EMOJIS) + emoji2 = random.choice(HEART_EMOJIS) + embed = discord.Embed( + title="Zodic Compatibility", + description=f'{zodiac_sign.capitalize()}{emoji1}{compatible_zodiac["Zodiac"]}\n' + f'{emoji2}Compatibility meter : {compatible_zodiac["compatibility_score"]}{emoji2}', + color=Colours.pink + ) + embed.add_field( + name=f'A letter from Dr.Zodiac {LETTER_EMOJI}', + value=compatible_zodiac['description'] + ) + await ctx.send(embed=embed) + + +def setup(bot): + bot.add_cog(ValentineZodiac(bot)) + log.info("ValentineZodiac cog loaded") diff --git a/bot/seasons/valentines/whoisvalentine.py b/bot/seasons/valentines/whoisvalentine.py new file mode 100644 index 00000000..59a13ca3 --- /dev/null +++ b/bot/seasons/valentines/whoisvalentine.py @@ -0,0 +1,54 @@ +import json +import logging +from pathlib import Path +from random import choice + +import discord +from discord.ext import commands + +from bot.constants import Colours + +log = logging.getLogger(__name__) + +with open(Path("bot", "resources", "valentines", "valentine_facts.json"), "r") as file: + FACTS = json.load(file) + + +class ValentineFacts(commands.Cog): + def __init__(self, bot): + self.bot = bot + + @commands.command(aliases=('whoisvalentine', 'saint_valentine')) + async def who_is_valentine(self, ctx): + """ + 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.channel.send(embed=embed) + + @commands.command() + async def valentine_fact(self, ctx): + """ + Shows a random fact about Valentine's Day. + """ + embed = discord.Embed( + title=choice(FACTS['titles']), + description=choice(FACTS['text']), + color=Colours.pink + ) + + await ctx.channel.send(embed=embed) + + +def setup(bot): + bot.add_cog(ValentineFacts(bot)) + log.info("ValentineFacts cog loaded") |