diff options
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") | 
