aboutsummaryrefslogtreecommitdiffstats
path: root/bot/seasons
diff options
context:
space:
mode:
Diffstat (limited to 'bot/seasons')
-rw-r--r--bot/seasons/__init__.py2
-rw-r--r--bot/seasons/christmas/adventofcode.py15
-rw-r--r--bot/seasons/easter/__init__.py17
-rw-r--r--bot/seasons/evergreen/__init__.py2
-rw-r--r--bot/seasons/evergreen/error_handler.py101
-rw-r--r--bot/seasons/evergreen/fun.py38
-rw-r--r--bot/seasons/evergreen/magic_8ball.py36
-rw-r--r--bot/seasons/evergreen/snakes/__init__.py10
-rw-r--r--bot/seasons/evergreen/snakes/converter.py80
-rw-r--r--bot/seasons/evergreen/snakes/snakes_cog.py1189
-rw-r--r--bot/seasons/evergreen/snakes/utils.py700
-rw-r--r--bot/seasons/evergreen/uptime.py4
-rw-r--r--bot/seasons/halloween/candy_collection.py6
-rw-r--r--bot/seasons/halloween/hacktoberstats.py4
-rw-r--r--bot/seasons/halloween/halloween_facts.py5
-rw-r--r--bot/seasons/halloween/halloweenify.py4
-rw-r--r--bot/seasons/halloween/monstersurvey.py6
-rw-r--r--bot/seasons/halloween/scarymovie.py4
-rw-r--r--bot/seasons/halloween/spookyavatar.py6
-rw-r--r--bot/seasons/halloween/spookygif.py4
-rw-r--r--bot/seasons/halloween/spookyreact.py6
-rw-r--r--bot/seasons/halloween/spookysound.py4
-rw-r--r--bot/seasons/pride/__init__.py17
-rw-r--r--bot/seasons/season.py29
-rw-r--r--bot/seasons/valentines/be_my_valentine.py241
-rw-r--r--bot/seasons/valentines/lovecalculator.py107
-rw-r--r--bot/seasons/valentines/movie_generator.py66
-rw-r--r--bot/seasons/valentines/myvalenstate.py85
-rw-r--r--bot/seasons/valentines/pickuplines.py44
-rw-r--r--bot/seasons/valentines/savethedate.py45
-rw-r--r--bot/seasons/valentines/valentine_zodiac.py59
-rw-r--r--bot/seasons/valentines/whoisvalentine.py54
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")